Merge branch 'worktree-config-settings-seam' into worktree-llm-dynamic-config

# Conflicts:
#	docs/capability-seams.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/module-graph.md
#	examples/tui-agent/cordis.yml
#	packages/README.i18n.yaml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/README.zh.md
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/README.zh.md
#	packages/settings/settings-local/src/index.ts
#	packages/util/README.i18n.yaml
#	packages/util/README.md
#	packages/util/README.zh.md
#	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-07-30 14:33:36 +08:00
1355 changed files with 58514 additions and 9928 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: 48fa3272f7e024a295e7beaa68b9365aac379319
README.zh.md: 686a9123f5f89ac244f8ef880e78609a131eb8b9
README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b
README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462

View File

@@ -11,6 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| Group | Role | Release expectation |
|---|---|---|
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
@@ -46,11 +47,14 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
| [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table.
New packages join existing groups; new groups update their README and this table.
## Dependencies

View File

@@ -11,6 +11,7 @@
| 组 | 职责 | 发布预期 |
|---|---|---|
| [`core/`](core/README.md) | 产品 API 主干会话、提示词、工具、agent智能体服务与具体循环 | 产品:稳定表面 |
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定表面 |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列spawn seam + 本地进程树实现 | 产品:稳定表面 |
@@ -46,11 +47,14 @@
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
| [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
| [`host/`](host/README.md) | web GUI 宿主半侧API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
| [`client/`](client/README.md) | web GUI 浏览器半侧shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
| [`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 和此表。
新包加入现有组;新组更新其 README 和此表。
## 依赖

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/acp/README.md
README.md: 326615210e5cfc39004fc5ab7462623089ac4126
README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c
README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa

View File

@@ -6,6 +6,6 @@ ACPAgent Client Protocol组将 harness 中的 agent智能体公开
| 包 | 职责 |
|---|---|
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 |
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 |
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84
README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent智能体、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACPAgent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent智能体、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
此包package是传输适配器而非 UI 集成或能力 seam。它不公开编辑器导航、transcript文本记录回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。
此包package是传输适配器而非 UI 集成或能力 seam。它不公开编辑器导航、transcript文本记录回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与向用户提问属于 Web 和 TUI 模块。
## 插件
@@ -15,7 +15,7 @@
| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 |
| `model` | 无 | 每个已创建 agent 的初始模型。 |
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。
## 协议契约
@@ -23,19 +23,19 @@
|---|---|
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
| `session/new` | 使用绝对`cwd` 创建新 agent接受空的 `additionalDirectories``mcpServers`,拒绝非空值。 |
| `session/prompt` | 接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并该请求拥有的持久 `turn/end` 结算。 |
| `session/cancel` | 仅取消被定址的 agent并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
| `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/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer
一个连接可以拥有多个会话。桥接层带品牌的会话 id 为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器
已提交消息输出有意逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
## 生命周期
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle并等待它们的循环会话清理完成。因此 ACP 插件重载不会遗留 agent。
客户端断开连接与 Cordis 的 dispose资源释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose并等待它们的循环会话清理完成。因此单独重载 ACP 插件不会遗留孤儿 agent。
## 运行
@@ -45,13 +45,13 @@
### 提示词文本
#### 模型所见内容
#### 模型看到的内容
`session/prompt` 文本块会原样接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
`session/prompt` 文本块会原样接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
#### Token 影响
提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。
提示词 token 取决于数据,并保留在该会话的历史中直到上下文压缩context compaction。并发 ACP 会话保留独立上下文。
#### KV Cache 影响
@@ -59,21 +59,21 @@
### 权限决策
#### 模型所见内容
#### 模型看到的内容
没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果
不会直接看到任何内容。所属工具通过常规工具结果路径记录其结果:允许、拒绝、取消或不可用。
#### Token 影响
只有拥有该决策的工具结果会贡献 token。
只有工具结果会贡献 token。
#### KV Cache 影响
通过所属工具结果仅追加。
随该工具结果仅追加。
## 已知限制与延后工作
## 已知限制与暂缓事项
- **仅新会话**:不支持加载、列出、恢复、删除和 fork。
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会展平为文本引用,而不是已获取内容。
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线
- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现会话关闭。
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会展平为文本引用,不会获取内容。
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输
- **连接管理的生命期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能

View File

@@ -87,8 +87,8 @@ describe('ACP prompt lifecycle', () => {
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
let inserted = false
harness.ctx.on('agent/inbox/enqueue', (subject, message) => {
if (subject !== agent || message.source.kind !== 'user' || inserted) return
harness.ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject !== agent || item.message.source.kind !== 'user' || inserted) return
inserted = true
const source = { kind: 'plugin', plugin: 'test' } as const
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/bash/README.md
README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db
README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
规范的三包能力 seam见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。
规范的三包能力 seam见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
@@ -11,4 +11,4 @@
| `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/))。
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516
README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess``bash -c <command>` 作为受管进程组 spawn拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级dispose资源释放)归进程管理器服务所有
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess``bash -c <command>` 作为受管进程组 spawn负责所有 Bash 职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级dispose资源释放等进程组机制则由 subprocess 服务负责
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`
@@ -24,11 +24,11 @@
设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`记录了两种已验证的有状态设计Claude Code 仅持久化 cwdCodex 使用 PTY exec 会话),供真实工作流需要时采用。
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`记录了两种已验证的有状态设计Claude Code 仅持久化 cwdCodex 使用 PTY exec 会话),供真实工作流需要时采用。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [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()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项id、所有权、轮询、通知都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
- **后台进程**`start()` 会立即返回活动的 `BashProcess` 句柄不应用超时Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项id、所有权、轮询、通知都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
## 模型体验
@@ -36,12 +36,12 @@
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
## 已知限制与暂缓事项
- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。
- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。
- **仅支持 POSIX**`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。
- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
README.md: ca77a9c626784b29145712535d69de4afbd3a697
README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3
README.zh.md: 4ecc8d533f7af373bdacd133d44a8def6d265868

View File

@@ -2,11 +2,11 @@
[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` 能力并添加升权字段。
这是使用沙箱能力的 [`@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 侧。
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的已包装argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行本包只负责 bash 侧。
| 模式 | 文件影响 |
|---|---|
@@ -17,12 +17,12 @@
语义:
- **拒绝是结果事实。** 如果一次失败运行的 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 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。
- **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`),由它驱动本包遵守的覆盖。
seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包遵守的模式覆盖
```yaml
- id: sandbox
@@ -36,7 +36,7 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
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/)。
无密钥消费方集成证明是 `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/)。
## 模型体验
@@ -44,11 +44,11 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
#### 模型看到的内容
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制`sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。
基线是生成的 [`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。
`bash` 可见的请求上schema 固定增加少量内容;模式切换不增加上下文 token。
#### KV Cache 影响
@@ -62,29 +62,29 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
#### Token 影响
除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记并保留到压缩
除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记并保留到上下文压缩context compaction
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
### 间接的 Bash 工具错误
#### 模型看到的内容
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
#### Token 影响
该次调用可见的是有条件错误文本,保留在历史记录中直到压缩。
该次调用会在相应条件下显示错误文本,该文本会保留在历史记录中直到上下文压缩。
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与暂缓事项
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339
README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c

View File

@@ -4,7 +4,7 @@
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么即运行前台命令与启动后台进程但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
本包package是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
| 包 | 职责 |
|---|---|
@@ -13,19 +13,19 @@
| `@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` 接口之后(本地 shellSSHVM 后端Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
该拆分与 LLM(大语言模型) seam`LlmService``LlmAdapter`)及 agent(智能体)工具调研结果一致pi 将执行隐藏在 `BashOperations` 接口之后(本地 shellSSHVM 后端Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
## 服务 API`ctx.bash`
| 成员 | 语义 |
|---|---|
| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 |
| `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 安全测试。
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。
## 词汇
@@ -33,7 +33,7 @@
每会话沙箱模式覆盖词汇(`'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`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 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)。
`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`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Noteagent 决策记录)](../../../.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)。
## 模型体验
@@ -41,9 +41,9 @@
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
## 已知限制与暂缓事项
- **没有交互式输入词汇**`stdin` 只会在 spawn 时写入一次并关闭seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。
- **前台超时始终由执行器拥有**seam 上调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。
- **前台超时始终由执行器负责**seam 上调用方负责 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92
README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b

View File

@@ -57,7 +57,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef
## UI presentation
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
## The tool builds its request from named args only
@@ -153,6 +153,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual.
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.

View File

@@ -57,7 +57,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
## UI 展示
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id因此使用通用执行卡片通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 task id因此使用通用执行卡片通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
## 工具仅使用具名参数构建请求
@@ -153,6 +153,6 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
## 已知限制与延期工作
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill这是仅影响展示的已知残留问题。
- **回放退出状态 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。

View File

@@ -296,7 +296,9 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
// The exit marker becomes the card's exit pill, so it leaves the output body.
const { body, ...exit } = parseExitStatus(raw)
return { card: 'terminal', output: body, ...exit }
}
/**

View File

@@ -95,10 +95,23 @@ export function renderProcessRead(
}
/**
* Recover the structured exit status from a rendered {@link renderResult}
* string — the inverse of the status markers it appends. A killed marker
* yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
* means a clean exit 0.
* The exit status recovered from a rendered result, with the output body that
* status was split off from.
*/
export type ParsedExitStatus =
& { body: string }
& ({ exitCode: number } | { signal: string })
/**
* Split a rendered {@link renderResult} string into its output body and the
* structured exit status — the inverse of the status markers it appends. A
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
* absent both means a clean exit 0.
*
* The consumed marker is removed from `body` because a terminal presentation
* shows the exit status as its own pill: leaving the marker in the output would
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
* pill shows, so they stay in the body.
*
* Replay only retains the rendered content text, not the original
* `BashRunResult`, so terminal presentation must recover the exit pill here.
@@ -106,12 +119,12 @@ export function renderProcessRead(
* that merely ends with marker-like text from matching unless the final line
* is indistinguishable from a real marker.
* @param text - rendered model-facing bash result.
* @returns the recovered terminal exit code or signal.
* @returns the marker-free body plus the recovered terminal exit code or signal.
*/
export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
export function parseExitStatus(text: string): ParsedExitStatus {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
return { body: text, exitCode: 0 }
}

View File

@@ -911,22 +911,32 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')!.presentResult!(
{ command: 'echo hi', description: 'echo' },
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
{ command: 'printf "hi\\n\\n"', description: 'echo' },
// A clean run renders no exit marker at all, so the body is the raw bytes.
{ content: [{ type: 'text', text: 'hi\n\n' }], isError: false },
)
// A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
// needs; the bridge derives the fenced fallback. exitCode is parsed back from
// the [exit code: N] marker.
expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 })
// needs; the bridge derives the fenced fallback.
expect(present).toEqual({ card: 'terminal', output: 'hi\n\n', exitCode: 0 })
})
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 })
expect(nonzero).toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
expect(killed).toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
})
it('bash presentResult: markers a pill CANNOT show (timeout, sandbox denial) stay in the terminal output', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
const timedOut = ctx.tools.get('bash')!.presentResult!(
args,
{ content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
)
expect(timedOut).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
})
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
@@ -952,8 +962,11 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
const rendered = renderResult(c.result)
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
// Drop card + output; the remaining fields are the parsed exit.
const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
expect(exit).toEqual(c.expect)
// Whatever the parse consumed is gone from the body, so a card with an exit
// pill never shows the same status twice.
expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
}
})
@@ -964,6 +977,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
// Unparsed marker-like text is real output, so it is NOT stripped from the body.
// Same for a fake signal marker with no leading newline.
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })

View 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/README.md
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8

34
packages/client/README.md Normal file
View File

@@ -0,0 +1,34 @@
# client/ — web-GUI browser half
English | [中文](README.zh.md)
The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
| Package | Role | ctx key / slot |
|---|---|---|
| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.

View File

@@ -0,0 +1,34 @@
# client/ — web GUI 浏览器半侧
[English](README.md) | 中文
dsh web GUI 的浏览器侧shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`
| 包 | 角色 | ctx 键slot |
|---|---|---|
| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
| `modules/` | 客户端模块系统Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
| `runtime/` | 客户端 cordis 启动与无 React 对象服务slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
| `locale/` | 浏览器语言偏好(`zh``en`)与 ns×locale 词典注册表 | `ctx.locale` |
| `ui-slots/` | slot 注册表纯核心SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light``dark``system` | `ctx.theme` |
| `ui-primitives/` | 纯 React 原子图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
| `ui-layout/` | shell 三栏 AppFrame声明 `sidebar``conversation``details``conversation.empty` | `ctx.layout` |
| `ui-sidebar/` | 侧栏 shellWorkspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | slot 宿主) |
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces``conversation.hero.workspace` |
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | slot 宿主) |
| `ui-trajectory/` | TrajectoryWaterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view` |
| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
| `ui-slash/` | 输入触发流水线:光标下的 `/``@` 检测、分组候选菜单、源名册 | `ctx.slash` |
| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash` |
| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash` |
| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer` |
| `ui-settings/` | 设置 shell触发 chrome + 模态面板;声明 `settings.*` slot | slot 宿主) |
| `ui-settings-general/` | 设置的无主文案chrome 内容 + General 分区骨架 | (填充 `settings.*` |
| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section` |
特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4

View File

@@ -4,6 +4,10 @@ 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.
## /api browser-trust fence
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.

View File

@@ -4,6 +4,10 @@
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取EventSource、图片、导航——这些头只发给可信目标既不带 `Origin` 也不带 Fetch-Metadata因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取而 Host 是重绑唯一伪造不了的请求头非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`部署需要让自己的服务权威被信任dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。

View File

@@ -33,7 +33,8 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
"@deepseek-ai/dsh-tools": "workspace:^",
"schemastery": "^3.18.0"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,129 @@
/**
* Browser-trust fence for every /api request. Defends the two confused-deputy
* paths a browser opens against a local HTTP API — DNS rebinding (Host names
* the attacker's domain while the socket reaches this server) and cross-site
* requests fired from a malicious page. The Host fence binds every request,
* browser-looking or not: over plain HTTP a browser attaches neither Origin
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
* headers go only to trustworthy destinations), so an unmarked request may
* still be a rebound browser read and Host is the one header rebinding cannot
* forge. Non-browser and remote clients pass the same fence via loopback, the
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
* Network reachability and authentication stay out of scope: binding policy
* belongs to the webserver config, and this fence is not an auth layer.
*/
import type { IncomingHttpHeaders } from 'node:http'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
return new URL(`http://${authority}`)
} catch {
return undefined
}
}
/**
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
* (case aside). Anything parsing would silently rewrite is refused as a typo
* that must fail the load loudly instead of being ignored until requests 403
* or quietly changing the grant: URL parts beyond the authority
* (`harness.internal/path`, `user@harness.internal` — which would authorize
* the embedded hostname), stripped whitespace, a dangling colon or
* zero-padded port (which would broaden an intended exact-port grant to every
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
* carries).
* @param entry - the configured value, verbatim.
*/
export function assertTrustedAuthority(entry: string): void {
const entryUrl = parseAuthority(entry)
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
}
/**
* Canonical form of a parsed authority: `hostname` when no port was written,
* else `hostname:port`. The port is judged from URL parses under both special
* schemes (their default ports differ, so `:80` and `:443` still count as
* explicit), never from the raw string, where WHATWG trimming would misread
* shapes like `host:port ` as port-less.
*/
function canonicalAuthority(entry: string, entryUrl: URL): string {
// An authority that parsed under http cannot fail under https.
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
}
/**
* Whether the request authority matches a `trustedHosts` entry. An entry with
* an explicit port matches that exact authority; a port-less entry matches the
* hostname on any port (the shape the CLI derives for IP-literal LAN serving,
* where the bound port may be OS-assigned). Both sides compare through WHATWG
* normalization, so case and a redundant `:80` never decide trust.
*/
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
: entryUrl.host === hostUrl.host
})
}
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - node HTTP request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
*/
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
// Host fence (DNS-rebinding defense), applied to every request: the browser
// fills Host from the URL it believes it is talking to, so a rebound page
// carries the attacker's domain here even though the socket lands on this
// server. There is no marker shortcut — a browser read over plain HTTP
// (EventSource, images, navigations) arrives with neither Origin nor
// Fetch-Metadata, indistinguishable from curl, and its response is readable
// by the rebound page.
const host = header(request.headers, 'host')
if (host === undefined) return false
const hostUrl = parseAuthority(host)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
// Cross-site fence: modern browsers label the initiator relationship on
// every fetch; an explicit cross-site marker is refused regardless of Origin.
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
// Origin fence: when a browser attaches an Origin it must be exactly this
// authority (compared through the same normalization as the Host). Absent
// Origin is fine — the Host fence above already bound the request. The
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
const origin = header(request.headers, 'origin')
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}

View File

@@ -8,10 +8,11 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

View File

@@ -80,6 +80,62 @@ const MARKDOWN_FIXTURE = [
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
/**
* SGR wrapper for the terminal output sample below: authoring the escapes as
* `\u001b` keeps literal control bytes out of this source file.
* @param code - the SGR parameter (an ANSI color or attribute number).
* @param body - the text the attribute applies to.
* @returns the body wrapped in the attribute and a reset.
*/
function sgr(code: number, body: string): string {
return `\u001b[${code}m${body}\u001b[0m`
}
/**
* Terminal output sample for fixture turn 65, authored to carry every feature
* the terminal card draws that turn 60's two prompt rows cannot reach:
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
* height cap collapses the middle. The exit status is authored separately in
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
* terminal card shows the exit as its own pill and leaving the marker in would
* render it twice (packages/bash/tool-bash/src/render.ts).
*/
const TERMINAL_OUTPUT_FIXTURE = [
sgr(1, 'Running 4 checks'),
`${sgr(32, '\u2713')} typecheck 1.82s`,
`${sgr(32, '\u2713')} lint 0.94s`,
`${sgr(32, '\u2713')} duplication 2.10s`,
`${sgr(31, '\u2717')} unit 8.41s`,
'',
sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
` ${sgr(31, 'FAIL')} caps output at the configured line budget`,
' expected 16 lines, received 24',
'',
'NAME LINES BRANCHES FUNCTIONS UNCOVERED',
'TerminalBlock.tsx 100% 100% 100% -',
'ansi.ts 100% 100% 100% -',
'clipboard.ts 100% 100% 100% -',
'CodeBlock.tsx 98.4% 96.2% 100% 41-43',
'highlight.ts 100% 100% 100% -',
'Pill.tsx 100% 100% 100% -',
'StateDot.tsx 100% 100% 100% -',
'markdown/Markdown.tsx 100% 100% 100% -',
'',
sgr(31, '1 of 4 checks failed'),
].join('\n')
/**
* Exit status for each terminal sample, keyed by its output text. Authored
* alongside the sample rather than parsed back out of its trailing marker,
* which is the bash tool's own job and not something to reimplement here.
*/
const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
// A two-line command, so the fixture covers the terminal card's one-row-per-
// command-line prompt (and that the card still marks the call exactly once).
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
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"}', '已写入')
@@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] {
{ content: '实现 fixture 样本', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
// whose prompt label is its last segment, and a non-zero exit authored beside
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
// `[exit code: N]` marker, since the real presenter consumes that one out of
// the body. Named `bash`, so it also covers
// the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
// row) — the two chat-row shapes the terminal card renders in.
//
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
// next `turn/start`, so a turn appended after it would leave the dock's plan
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -250,7 +322,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
return undefined
}
switch (name) {
// Both names present the same terminal card: `fx-bash` lands on the
// render-site fallback row, `bash` on the keyed BashRow registration.
case 'fx-bash':
case 'bash':
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
case 'fx-write':
return {
@@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
if (call === undefined) return undefined
switch (call.card) {
case 'terminal':
return { card: 'terminal', output: resultText, exitCode: 0 }
// The sample's own exit status, authored beside it: re-parsing the
// trailing marker here would duplicate the bash tool's `parseExitStatus`,
// which this client-side fixture cannot import.
return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
case 'diff':
return { card: 'diff', diffs: call.diffs }
case 'generic':
@@ -331,6 +409,44 @@ function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: b
}
/** Fixture parallel of the host's projection units: whole current values per key over the full log. */
/** Fixture preset table (the host PermissionService defaults). */
const PERMISSION_PRESETS: Record<string, { sandbox: string; approval: string; description: string }> = {
'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' },
}
/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */
function permissionSelectOf(
log: readonly SessionEvent[],
): { options: { value: string; name: string; description?: string }[]; currentValue: string } {
let preset: string | null = null
let sandbox = 'workspace-write'
let approval = 'ask'
for (const event of log) {
const item = event as { type: string; data: Record<string, unknown> }
if (item.type === 'permission/preset') preset = item.data['preset'] as string
else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string
else if (item.type === 'approval/policy') approval = item.data['policy'] as string
}
const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval
let currentValue = 'custom'
const folded = preset === null ? undefined : PERMISSION_PRESETS[preset]
if (preset !== null && folded !== undefined && matches(folded)) {
currentValue = preset
} else {
for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) {
if (matches(spec)) { currentValue = name; break }
}
}
return {
options: [
...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })),
...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [],
],
currentValue,
}
}
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
const values: Record<string, unknown> = {}
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
@@ -339,6 +455,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
}
// Always present (tool-todo unit composed): null when no plan stands.
values['todos'] = backscanTodos(log) ?? null
// Always present (permission service composed): the whole select.
values['permissions'] = permissionSelectOf(log)
// Always present (plan-mode unit composed): the {active, pending} view.
values['plan'] = planViewOf(log)
// Always present (GoalService unit composed): null before create / after clear.
@@ -373,6 +491,16 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
seq: event.seq,
}]
}
// Knob fold: any of the three whole-value knob events advances the select.
if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') {
return [{
type: 'session/projection',
sessionId: id,
key: 'permissions',
value: permissionSelectOf(log),
seq: event.seq,
}]
}
// The plan unit advances on its two folded event kinds.
if (type === 'plan/mode' || (type === 'command/run'
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
@@ -473,7 +601,7 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
@@ -575,9 +703,43 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
// In-memory browse tree behind the fixture's `browse` picker capability —
// deterministic content mirroring the design mock so assembled Web tests
// and snapshots can walk it. Leaves are materialized lazily: a child listed
// by its parent lists as empty until something is created inside it.
const FIXTURE_HOME = '/home/fixture'
const directoryTree = new Map<string, string[]>([
['/', ['home']],
['/home', ['fixture']],
[FIXTURE_HOME, ['Documents', 'Downloads', '.config']],
[`${FIXTURE_HOME}/Documents`, [
'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform',
'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog',
]],
])
const childrenOf = (path: string): string[] | undefined => {
const known = directoryTree.get(path)
if (known !== undefined) return known
const parent = path.slice(0, path.lastIndexOf('/')) || '/'
const name = path.slice(path.lastIndexOf('/') + 1)
return directoryTree.get(parent)?.includes(name) === true ? [] : undefined
}
const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => {
const crumbs = [{ name: '/', path: '/', hidden: false }]
let acc = ''
for (const segment of path.split('/').filter(Boolean)) {
acc += `/${segment}`
crumbs.push({ name: segment, path: acc, hidden: false })
}
return crumbs
}
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
/** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const pendingApprovalId = 'fx-approval-1' as Extract<MuxFrame, { type: 'approval/requested' }>['approvalId']
/** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */
let approvalPending = true
const pendingQuestionRpcId = mint()
let questionPending = true
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
@@ -859,6 +1021,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
return ok(request, { sessionId: created.sessionId })
},
rename: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const { sessionId, title } = request.payload
const normalized = title.trim().replace(/\s+/g, ' ')
if (normalized.length === 0) {
return err(request, {
code: 'title-invalid',
message: 'session title must contain visible characters',
details: { sessionId },
})
}
// The append emits the session/event and its session/projection frame
// (host parallel); the unary response settles the caller first.
append(sessionId, {
type: 'session/title',
data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
})
const appended = logOf(sessionId).at(-1) as SessionEvent
return ok(request, { title: normalized, seq: appended.seq })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -967,6 +1150,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
)
return ok(request, { accepted: true as const })
},
updateQueue: request => err(request, {
code: 'queue-item-not-found',
message: 'fixture has no pending queue item',
details: { itemId: request.payload.itemId },
}),
cancel: (request) => {
const replay = replays.get(request.payload.sessionId)
if (replay !== undefined) {
@@ -980,7 +1168,42 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
pickDirectory: request => ok(request, { path: null }),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
listDirectory: (request) => {
const target = request.payload.path ?? FIXTURE_HOME
const children = childrenOf(target)
if (children === undefined) {
return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } })
}
return ok(request, {
path: target,
home: FIXTURE_HOME,
crumbs: crumbsOf(target),
entries: [...children].sort((a, b) => a.localeCompare(b))
.map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
// The fixture tree is tiny; no level ever reaches a backend bound.
truncated: false,
})
},
createDirectory: (request) => {
const parent = request.payload.path
const children = childrenOf(parent)
if (children === undefined) {
return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
}
// Same root special case as listDirectory's entry paths: a plain join
// under '/' would mint '//name' and fork the tree's identity.
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
if (children.includes(request.payload.name)) {
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
}
directoryTree.set(parent, [...children, request.payload.name])
directoryTree.set(target, [])
return ok(request, { path: target })
},
openPath: request => ok(request, { opened: true as const }),
},
workspace: {
@@ -1082,6 +1305,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
{ name: 'compact', description: 'fixture压缩当前会话上下文' },
{ name: 'echo', description: 'fixture回显参数', input: { hint: 'text to echo' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
],
})
@@ -1098,6 +1322,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
const name = match?.[1]
const args = match?.[2] ?? ''
// /permission mirrors the host handler: switch through the knob
// events (each append pushes a permissions projection frame).
if (name === 'permission') {
const preset = args.trim()
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
}
return ok(request, { matched: true as const, commandId })
}
if (name === 'goal') {
// Host parallel: /goal with an objective creates (or reports) the
// current goal; the command lifecycle pair brackets the mutation.
@@ -1232,14 +1476,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } })
}
}
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
if (approvalPending) {
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: pendingApprovalId,
toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)',
},
})
}
if (questionPending) {
conn.push({
rpcId: pendingQuestionRpcId,
@@ -1277,6 +1523,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
// audit correlation; a settled or unknown id is not-pending.
if (message.rpcId === pendingApprovalRpcId) {
if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
const value = message.result.value as { approvalId?: unknown; outcome?: unknown }
if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
approvalPending = false
emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome })
return Promise.resolve({ accepted: true })
}
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
@@ -1331,10 +1590,14 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.rename': return this.api.sessions.rename(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
case 'host.createDirectory': return this.api.host.createDirectory(request)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)

View File

@@ -13,10 +13,11 @@ import { WebApiClient } from './web-api-client.ts'
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -1,11 +1,12 @@
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import z from 'schemastery'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
export { API_PATH } from './api-path.ts'
@@ -15,20 +16,42 @@ export const name = 'client-connection'
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy']
/** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig {
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
})
/**
* Mounts the API gateway under the browser transport prefix.
* Mounts the API gateway under the browser transport prefix. Every request on
* the prefix passes the browser-trust fence first (DNS-rebinding and
* cross-site defense — [api-request-trust](./api-request-trust.ts)).
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
*/
export function apply(ctx: Context): void {
export function apply(ctx: Context, config?: ConnectionConfig): void {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
if ((pathname === `${API_PATH}/host.pickDirectory`
|| pathname === `${API_PATH}/host.openPath`)
&& !isTrustedNativeDialogRequest(req)) {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return

View File

@@ -1,52 +0,0 @@
/** Trust check for browser requests that can invoke privileged native host actions. */
import type { IncomingHttpHeaders } from 'node:http'
interface NativeDialogRequest {
headers: IncomingHttpHeaders
socket: { remoteAddress?: string | undefined }
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopback(address: string | undefined): boolean {
if (address === undefined) return false
if (address === '::1') return true
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
const first = ipv4.split('.')[0]
return first === '127'
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/**
* Require a local socket plus browser-controlled same-origin metadata.
* @param request - the node HTTP request facts used by the carrier guard.
* @returns true only for a same-origin browser request whose peer and URL are loopback.
*/
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
if (!isLoopback(request.socket.remoteAddress)) return false
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
const origin = header(request.headers, 'origin')
const host = header(request.headers, 'host')
if (origin === undefined || host === undefined) return false
try {
const parsed = new URL(origin)
const hostUrl = new URL(`http://${host}`)
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
&& parsed.host === host
&& isLoopbackHostname(parsed.hostname)
&& isLoopbackHostname(hostUrl.hostname)
} catch {
return false
}
}

View File

@@ -0,0 +1,108 @@
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
import { describe, expect, it } from 'vitest'
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
return { headers }
}
describe('isTrustedApiRequest', () => {
it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
// Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
// reads (EventSource, images, navigations), so a rebound-origin GET is
// markerless and its response readable: no marker shortcut may exist.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
expect(isTrustedApiRequest(request({}), [])).toBe(false)
})
it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
}
})
it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
expect(isTrustedApiRequest(request({
host: 'evil.example:3080',
origin: 'http://evil.example:3080',
'sec-fetch-site': 'same-origin',
}), [])).toBe(false)
})
it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
expect(isTrustedApiRequest(request(headers), [])).toBe(false)
})
it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
// An unparsable entry never matches; it must not poison the rest of the list.
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
})
it('refuses cross-origin browser markers even on a loopback Host', () => {
// Origin present and different → cross-site request that survived preflight rules.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
// Explicit cross-site label → refused regardless of Origin.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
// Opaque origin (sandboxed iframe, file: page) parses to no authority.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
})
it('accepts a same-origin browser request, with or without an Origin header', () => {
expect(isTrustedApiRequest(request({
host: 'localhost:3080',
origin: 'http://localhost:3080',
'sec-fetch-site': 'same-origin',
}), [])).toBe(true)
// Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
})
it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
}
// WHATWG parsing would quietly read a hostname out of each of these; the
// config boundary must refuse them instead of authorizing the prefix.
for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
// WHATWG trimming would silently strip these; the entry must fail instead.
for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
// WHATWG parsing would silently rewrite these — a dangling colon or
// zero-padded port would broaden an intended exact-port grant to every
// port, and non-canonical host spellings would not read back as written.
for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
})
it('never lets stray whitespace broaden an exact-port entry to every port', () => {
// Defense in depth below the load-time assert: the explicit-port judgment
// reads the parsed URL, so a trimmed `host:port ` entry stays exact.
const trusted = ['harness.internal:3080 ']
expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
})
it('refuses malformed or untrusted authorities on browser requests', () => {
const markers = { 'sec-fetch-site': 'same-origin' }
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
})
})

View File

@@ -45,6 +45,7 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
@@ -62,6 +63,7 @@ export class FakeApiClient implements IApiClient {
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -70,6 +72,18 @@ export class FakeApiClient implements IApiClient {
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -84,13 +98,17 @@ export class FakeApiClient implements IApiClient {
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
}

View File

@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
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', 'plan'])
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
// input hint rides only the commands declaring it.
const echo = commands.find(c => c.name === 'echo')
expect(echo?.input?.hint).toBeTruthy()

View File

@@ -72,8 +72,19 @@ describe('createFixtureApi', () => {
// Fixture composes the todos + plan units (host parallel when tool-todo
// and plan-mode are mounted): the empty-log values.
expect(empty.result.value).toEqual({
events: [], hasMore: false,
projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } },
events: [], hasMore: false, projections: { asOfSeq: -1, values: {
todos: null,
// Permission unit composed: the composition-default select.
permissions: {
options: [
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
],
currentValue: 'workspace-write',
},
plan: { active: false, pending: false },
goal: null,
} },
})
})
@@ -208,7 +219,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 7) abort.abort()
if (envelopes.length >= 8) abort.abort()
}
return envelopes
}
@@ -216,15 +227,16 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
// Projection baseline frames follow the subscribed frame (title + todos + plan + goal units).
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId)
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -311,6 +323,44 @@ describe('createFixtureApi', () => {
})).toEqual({ accepted: true })
})
it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => {
const api = createFixtureApi()
// Discover the resident approval's stable rpcId from the mux baseline.
const abort = new AbortController()
const seen: { rpcId: string; frame: MuxFrame }[] = []
const consuming = (async () => {
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload })
})()
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true)
})
const requested = seen.find(s => s.frame.type === 'approval/requested')
if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable')
const approvalId = requested.frame.approvalId
// Routed but malformed answers.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
// The real answer settles the question and broadcasts resolved.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } }))
.toEqual({ accepted: true })
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true)
})
// Settled: a duplicate answer is late, and a fresh mux open replays nothing.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
await consuming
const abort2 = new AbortController()
const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2)
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
})
it('describe answers the fixture identity', async () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
@@ -319,6 +369,22 @@ describe('createFixtureApi', () => {
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
})
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
const api = createFixtureApi()
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.path).toBe('/srv')
const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal)
if (!listed.result.ok) throw new Error('list failed')
expect(listed.result.value.crumbs).toEqual([
{ name: '/', path: '/', hidden: false },
{ name: 'srv', path: '/srv', hidden: false },
])
const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal)
if (!root.result.ok) throw new Error('root list failed')
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
})
it('workspace.list serves the resident account and create reuses on path collision', async () => {
const api = createFixtureApi()
const listed = await api.workspace.list(req({}))
@@ -398,6 +464,47 @@ describe('createFixtureApi', () => {
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
})
it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const framesPromise = (async () => {
const frames: MuxFrame[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
frames.push(envelope.payload)
if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort()
}
return frames
})()
await new Promise(resolve => setTimeout(resolve, 10))
const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } })
const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' }))
expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } })
const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' }))
if (!renamed.result.ok) throw new Error('rename failed')
expect(renamed.result.value.title).toBe('重命名')
const acceptedSeq = renamed.result.value.seq
// The response seq addresses the appended title event (the client plane
// has no session/title in its event union — titles ride the projection —
// so the event is located by seq and its payload checked structurally).
const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 }))
if (!history.result.ok) throw new Error('history failed')
const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq)
expect(appended?.event).toMatchObject({
type: 'session/title',
data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } },
})
// Beyond the subscribe-time baseline replay, the append emitted exactly
// one title projection frame carrying the new value at the response seq.
const frames = await framesPromise
const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名')
expect(titleFrames).toHaveLength(1)
expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq })
})
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
const api = createFixtureApi()
const wsid = 'fx-ws-fixture' as WorkspaceId

View File

@@ -1,57 +0,0 @@
import type { IncomingHttpHeaders } from 'node:http'
import { describe, expect, it } from 'vitest'
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
function request(
remoteAddress: string | undefined,
headers: IncomingHttpHeaders = {
host: '127.0.0.1:3080',
origin: 'http://127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
},
) {
return { socket: { remoteAddress }, headers }
}
describe('native dialog request trust', () => {
it('accepts loopback same-origin browser requests', () => {
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::1', {
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
})
it('rejects remote sockets and requests without matching browser metadata', () => {
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
})
})

View File

@@ -1,4 +1,6 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { EventEmitter } from 'node:events'
import { Readable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
/** Structural httpServer fake: the plugin only touches register(). */
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
return {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
}
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
function fakeRequest(headers: Record<string, string>): IncomingMessage {
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
return request
}
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {}
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number) { state.status = value; return this },
write() { return true },
end(this: { writableEnded: boolean }, value?: unknown) {
if (value !== undefined) state.body = value
this.writableEnded = true
return this
},
}) as unknown as ServerResponse
return { response, state }
}
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, dispose: () => fiber.dispose() }
}
describe('connection node half', () => {
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
// The apply throw also escapes cordis as a late rejection — the shape the
// boot's installFailLoud is contracted to catch. Capture it so the run
// stays clean, same pattern as the webserver bind-failure test.
const rejections: unknown[] = []
const onUnhandled = (err: unknown): void => { rejections.push(err) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
for (let i = 0; i < 100 && rejections.length === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('registers the /api prefix route and removes it with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
let status: number | undefined
let body: unknown
const deniedRequest = {
url,
headers: {
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
},
socket: { remoteAddress: '192.168.1.8' },
} as unknown as IncomingMessage
const deniedResponse = {
writeHead(value: number) { status = value; return this },
end(value?: unknown) { body = value; return this },
} as unknown as ServerResponse
await routes[0]!.handler(deniedRequest, deniedResponse)
expect(status).toBe(403)
expect(body).toBe('forbidden')
}
await fiber.dispose()
await dispose()
expect(routes).toHaveLength(0)
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
const { routes, dispose } = await mounted()
const { response, state } = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
}), response)
expect(state.status).toBe(403)
expect(state.body).toBe('forbidden')
await dispose()
})
it('passes loopback and declared-authority requests through to the bridge', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
// Loopback, no browser markers (curl shape): the fence passes; the carrier
// answers 404 for a GET unary path — proof the bridge ran.
const loopback = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
expect(loopback.state.status).toBe(404)
// LAN authority declared as a port-less IP literal — the shape the CLI
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
const lan = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
expect(lan.state.status).toBe(404)
// Declared public authority, same-origin browser shape.
const declared = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
}), declared.response)
expect(declared.state.status).toBe(404)
await dispose()
})
})

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
浏览器侧订阅系统 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 通道。
浏览器侧订阅系统 SSEServer-Sent Events通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate``registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
## 模型体验
@@ -12,10 +12,10 @@
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiberSession 对象不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容rev 刷新会随重新连接握手机制落地
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiberSession 对象不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容rev 刷新将在重新连接握手机制中实现

View File

@@ -32,7 +32,7 @@ const install: InvariantInstaller = (ctx, fail) => {
const baselines = new WeakMap<Fiber, number>()
// Async listener by design: emitPluginDisposed awaits-and-logs returned
// promises, so a violation surfaces loudly instead of unhandled.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
// oxlint-disable-next-line typescript/no-misused-promises
ctx.on('internal/plugin', async (fiber) => {
if (fiber.name !== 'client-hmr') return
if (fiber.uid !== null) {

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: c2adbcabc77def740094288da4643032873aa5b8
README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59

View File

@@ -2,7 +2,7 @@
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).
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
## Model Experience
@@ -14,5 +14,5 @@ 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.
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件LocaleService 包含浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 为键持久化;提供 gettersetter并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
locale 插件LocaleService——浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → zh → key。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
## 模型体验
@@ -10,9 +10,9 @@ locale 插件LocaleService 包含浏览器 locale 偏好(`zh``en`,以
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述在重新注册前保持注册时的语言slot 渲染的文案随切换实时更新

View File

@@ -5,23 +5,22 @@
* settings surface.
*/
import { useState } from 'react'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, 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. */
/** Injected business face: the preference write (t rides the standard locale seat). */
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. */
/** Full component props: runtime share + store share + locale seat + injected face. */
export type LanguageRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>>
& PropsLocale<'settings.locale'> & LanguageRowInjected
/**
* Render the Language row.

View File

@@ -4,11 +4,21 @@
* preference row into the settings General section — the locale feature owns
* its own settings surface.
*/
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
* ui-slots): in THIS unit the map holds only this package's own merges, but
* consumers merge more namespaces in and the intersection keeps them
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
import type { Context } from 'cordis'
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import {
deferRegistration,
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
import { en, zh, type CommonKey } from '../locales/index.ts'
import {
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
} from '../locales/settings.ts'
import type { LanguageRowInjected } from './LanguageRow.tsx'
import { LanguageRow } from './LanguageRow.tsx'
import { createLanguageRowStore } from './settings-store.ts'
@@ -16,9 +26,21 @@ 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'
export type { CommonKey } from '../locales/index.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
// The translate currency lives in ui-slots (the render machinery synthesizes
// the seat); re-exported here so dictionary owners import one package.
// TranslateNS<'model'> is the namespace-addressed developer-facing form.
export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */
common: CommonKey
/** This feature's own settings-row copy (the Language row). */
'settings.locale': SettingsLocaleKey
}
}
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
@@ -50,7 +72,10 @@ declare module 'cordis' {
}
interface Events {
/**
* Locale state changed (active locale switched or registry updated).
* The active locale switched. Dictionary registrations do NOT emit this
* event (listeners may re-register slots in response, and boot registers
* one namespace per package); continuous render refresh rides the
* LocaleFace revision instead.
* @param snapshot - Current immutable locale snapshot.
* @mode emit
*/
@@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
])
/**
* 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.
* Dictionary registry plus locale preference. Lookup chain per key: the
* entry's namespace in the active locale -> that namespace's zh fallback ->
* the shared common namespace (active, then zh) -> 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 through the `locale/change` event, or through the
* LocaleFace getSnapshot/subscribe pair the render machinery consumes
* (installed via `ctx.slots.installLocale`).
*/
export class LocaleService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private snapshot: LocaleSnapshot
private listeners = new Set<() => void>()
private readonly ctx: Context
/**
@@ -105,6 +134,27 @@ export class LocaleService {
return this.snapshot
}
/**
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
* reference between changes, uSES-safe).
* @returns the current snapshot.
*/
getSnapshot(): LocaleSnapshot {
return this.snapshot
}
/**
* LocaleFace subscribe: notified on every snapshot change (locale switch
* or dictionary registration — registrations bump the revision so already
* rendered outlets pick up late-arriving dictionaries).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
@@ -114,44 +164,80 @@ export class LocaleService {
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)
this.publish(match.id, true)
}
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* Register a declared namespace's dictionaries, all locales in one call —
* the typed form: each dictionary is checked against the namespace's
* {@link LocaleNamespaceMap} key union (a missing or extra key is a
* compile error), and every shipped locale is required (bilingual balance
* enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
* namespace's texts have one owner). Registration bumps the revision so
* mounted outlets pick up late-arriving dictionaries.
* @param ns - a namespace merged into LocaleNamespaceMap.
* @param dicts - complete dictionaries keyed by locale id.
* @returns disposer removing every locale registered by this call (idempotent).
*/
register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
/**
* Single-locale untyped form for namespaces outside the merge table
* (dynamic composition, tests).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param locale - locale tag.
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
register(ns: string, locale: string, dict: LocaleDict): () => void
register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string'
// Overload guarantees dict on the single-locale arm.
? [[localeOrDicts, dict as LocaleDict]]
: Object.entries(localeOrDicts)
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)
for (const [locale] of pairs) {
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
}
for (const [locale, entries] of pairs) locales.set(locale, entries)
this.publish(this.snapshot.active, false)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
/* v8 ignore next -- defensive: a namespace's locales map is created on
* first register and never removed, so the disposer always finds it. */
if (!owner) return
let removed = false
for (const [locale, entries] of pairs) {
if (owner.get(locale) === entries) {
owner.delete(locale)
removed = true
}
}
if (removed) this.publish(this.snapshot.active, false)
}
}
/**
* 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 a declared namespace to a translate function typed to its
* dictionary key union (plus the shared common vocabulary) — the same key
* domain the framework-injected `t` seat carries. The returned reference
* is stable per namespace (repeat binds return the same function), so it
* can ride inject surfaces without breaking memoization.
* @param ns - a namespace merged into LocaleNamespaceMap.
* @returns the typed translate function (reads the active locale at call time).
*/
bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
/**
* Untyped form for namespaces outside the merge table (dynamic
* composition, tests).
* @param ns - namespace.
* @returns the translate function.
*/
bind(ns: string): Translate
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
@@ -163,14 +249,43 @@ export class LocaleService {
}
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]
const template = this.lookup(ns, key)
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
private lookup(ns: string, key: string): string | undefined {
const locales = this.dicts.get(ns)
return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
}
/**
* Advance the snapshot revision and notify LocaleFace subscribers (render
* refresh). Only an active-locale switch additionally emits
* `locale/change` — dictionary registrations stay off the event so
* registration-heavy boot cannot storm event listeners (which may
* re-register slots in response).
*/
private publish(active: LocaleId, localeChanged: boolean): void {
this.snapshot = Object.freeze({
active,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
for (const fn of [...this.listeners]) {
try {
fn()
} catch (error) {
// One throwing subscriber must not strand the rest on a stale
// revision (outlets would keep the previous language).
console.error('locale subscriber crashed:', error)
}
}
}
}
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
@@ -208,11 +323,12 @@ export const inject = ['slots']
*/
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' })
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
ctx.provide('locale', locale)
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
// so the render machinery can synthesize the `t` standard seat.
ctx.slots.installLocale(locale)
const store = createLanguageRowStore()
let bound: BoundActions<typeof store> | undefined
@@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void {
// first render (the store's revision guard drops stale duplicates).
sync(locale.getLocale())
return {
t: locale.bind(SETTINGS_NS),
setLocale: (id) => { locale.setLocale(id) },
}
}
@@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void {
id: 'language',
order: 0,
store,
locale: SETTINGS_NS,
inject: injected,
}, LanguageRow))
return () => { deferred.dispose() }

View File

@@ -1,2 +1,29 @@
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const en: Record<string, string> = {}
import type { CommonKey } from './zh.ts'
/** en base dictionary for the common namespace, checked complete against the zh key set. */
export const en = {
'ok': 'OK',
'cancel': 'Cancel',
'close': 'Close',
'copy': 'Copy',
'copied': 'Copied',
'retry': 'Retry',
'loading': 'Loading…',
'load.failed': 'Failed to load',
'submit': 'Submit',
'submitting': 'Submitting…',
'next': 'Next',
'previous': 'Previous',
'skip': 'Skip',
'delete': 'Delete',
'edit': 'Edit',
'save': 'Save',
'search': 'Search',
'more': 'More',
'collapse': 'Collapse',
'expand': 'Expand',
'back': 'Back',
'unknown': 'Unknown',
'none': 'None',
'truncated': 'Truncated',
} satisfies Record<CommonKey, string>

View File

@@ -0,0 +1,8 @@
/**
* The common-namespace dictionary pair. zh is the source of truth for the
* key set (Chinese-first repo convention); en is checked complete against it
* — a missing or extra en key is a compile error.
*/
export { zh } from './zh.ts'
export { en } from './en.ts'
export type { CommonKey } from './zh.ts'

View File

@@ -0,0 +1,14 @@
/** `settings.locale` namespace dictionaries (the Language row's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'language.title': '语言',
} satisfies Record<string, string>
/** The settings.locale namespace key union. */
export type SettingsLocaleKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'language.title': 'Language',
} satisfies Record<SettingsLocaleKey, string>

View File

@@ -1,2 +1,30 @@
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const zh: Record<string, string> = {}
/** zh base dictionary for the common namespace: cross-feature standard words. */
export const zh = {
'ok': '确定',
'cancel': '取消',
'close': '关闭',
'copy': '复制',
'copied': '复制成功',
'retry': '重试',
'loading': '加载中…',
'load.failed': '加载失败',
'submit': '提交',
'submitting': '正在提交…',
'next': '下一步',
'previous': '上一步',
'skip': '跳过',
'delete': '删除',
'edit': '编辑',
'save': '保存',
'search': '搜索',
'more': '更多',
'collapse': '收起',
'expand': '展开',
'back': '返回',
'unknown': '未知',
'none': '无',
'truncated': '已截断',
} satisfies Record<string, string>
/** The common vocabulary key union (zh is the key-set source of truth). */
export type CommonKey = keyof typeof zh

View File

@@ -69,16 +69,18 @@ describe('locale apply', () => {
// An event ahead of any inject hits the unbound-actions arm.
locale.setLocale('en')
const { instance, face } = faceOf(b.slots)
const { entry, 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')
// Copy rides the standard locale seat: the entry declares the namespace.
expect(entry.locale).toBe(SETTINGS_NS)
expect(locale.bind(SETTINGS_NS)('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('语言')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {

View File

@@ -29,6 +29,24 @@ describe('LocaleService', () => {
expect(t('missing.key')).toBe('missing.key')
})
it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
const { svc } = make()
// The shipped common pair is registered by apply; the bench registers it
// directly to pin the production chain: ns -> common -> zh -> key.
svc.register('common', 'zh', { retry: '重试' })
svc.register('common', 'en', { retry: 'Retry' })
svc.register('ns', 'zh', { own: '自有' })
const t = svc.bind('ns')
expect(t('retry')).toBe('重试')
svc.setLocale('en')
expect(t('retry')).toBe('Retry')
expect(t('own')).toBe('自有')
// common itself must not recurse: a miss inside common echoes the key.
// (Wide-string ns hits the untyped bind overload — the typed one rejects
// unknown keys at compile time, which is the point of the seam.)
expect(svc.bind('common' as string)('nope')).toBe('nope')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const { svc } = make()
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
@@ -56,6 +74,47 @@ describe('LocaleService', () => {
expect(t('k')).toBe('v2')
})
it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => {
const { svc } = make()
const seen: number[] = []
const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
expect(svc.getSnapshot()).toBe(svc.getLocale())
const r0 = svc.getSnapshot().revision
svc.register('ns', 'zh', { k: 'v' })
expect(svc.getSnapshot().revision).toBe(r0 + 1)
svc.setLocale('en')
expect(seen).toEqual([r0 + 1, r0 + 2])
off()
svc.setLocale('zh')
expect(seen).toHaveLength(2)
})
it('isolates a throwing subscriber: the rest still see the new revision', () => {
const { svc } = make()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const seen: number[] = []
svc.subscribe(() => { throw new Error('boom') })
svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
svc.setLocale('en')
expect(seen).toEqual([1])
expect(spy).toHaveBeenCalledOnce()
} finally {
spy.mockRestore()
}
})
it('register disposer republishes (mounted outlets drop the dead dictionary)', () => {
const { svc } = make()
const dispose = svc.register('ns', 'zh', { k: 'v' })
const before = svc.getSnapshot().revision
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
// Second run hits the idempotent arm: nothing removed, no republish.
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
svc.setLocale('en')

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
客户端模块系统Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项fiber 生命周期、inject 等待、update/refresh并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
客户端模块系统Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项fiber 生命周期、inject 等待、update/refresh并把该包package`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 指向同一表层(一个插件组合包就是其包的客户端侧)。
惰性 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
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子
## 模型体验
@@ -18,5 +18,5 @@
## 已知限制与暂缓事项
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表接口loadCache/edges/invalidate按通用模块图塑形因此可以改变 externalization 粒度而不更改接口。
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`loader 只逐记录清点自身拥有的样式标签 id。
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点接口loadCache/edges/invalidate按通用模块图塑形因此可以改变 externalization 粒度而不更改接口。
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`loader 只在每条记录中登记其拥有的样式标签 id。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9
README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
## Workspace and Session lists
@@ -16,13 +16,17 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`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.
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## 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
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
## Session model selection

View File

@@ -2,19 +2,23 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create`瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页`projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create`时产生 Session、agent(智能体)和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部`projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。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`)。
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受`prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering中途引导不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`
## Code Mode 子调用索引
@@ -22,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## Session 标题投影
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支的标题。
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 会话模型选择
@@ -30,14 +34,14 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型体验
无,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
#### KV Cache 影响
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
更改目标可能改变提供方侧的缓存复用,或使其失效;该包package本身不会改变提示词前缀。
## 已知限制与暂缓事项
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配空状态 P0 事故复盘
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber dispose资源释放 → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包导入时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 事故复盘postmortem所记录的问题

View File

@@ -0,0 +1,35 @@
import type {
RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionHistoryInspection } from '../sessions/history.ts'
import type { ObservableSnapshot } from './store.ts'
/** Observable state of one independently loaded session history ledger. */
export interface SessionHistorySnapshot {
state: 'cold' | 'loading' | 'ready' | 'error'
error: RpcError | null
hasMore: boolean
inspection: SessionHistoryInspection
}
/** Read-only history source addressed by session id. */
export interface SessionHistoryFace
extends ObservableSnapshot<SessionHistorySnapshot> {
readonly sessionId: SessionId
/**
* Load the tail and exhaust every available older page.
* @param signal - Consumer lifetime; abort is observed between page requests.
* @returns When the available ledger is complete or stops advancing.
*/
loadAll(signal?: AbortSignal): Promise<void>
}
/** Runtime service resolving independent history sources. */
export interface ISessionHistory {
/**
* Resolve the identity-stable source for a session.
* @param sessionId - Host session identity.
* @returns The source owned outside Session and SessionManager.
*/
source(sessionId: SessionId): SessionHistoryFace
}

View File

@@ -8,7 +8,9 @@
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
InboxItemId, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -36,16 +38,37 @@ export interface ISession {
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Apply one mutation to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
/**
* Cancel the running turn.
* @returns acceptance, or the business error.
*/
cancel(): Promise<RpcResult<{ accepted: true }>>
/**
* Rename this session (explicit user title; pins it against automatic
* regeneration).
* @param title - raw title text (the host normalizes acceptance).
* @returns the normalized accepted title and its event seq, or the business error.
*/
rename(title: string): Promise<RpcResult<{ title: string; seq: number }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.
*/
loadOlder(): Promise<void>
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
*/
command(line: string): Promise<RpcResult<{ matched: boolean }>>
}
/**

View File

@@ -6,7 +6,7 @@
* the concrete class. Widening this interface is the explicit act of
* widening what features may do to the workspaces domain.
*/
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { WorkspaceListState } from '../workspaces/service.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -37,6 +37,20 @@ export interface IWorkspaces {
* @returns the selected path, or null when the user cancelled.
*/
pickDirectory(): Promise<string | null>
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry.
*/
listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
/**
* Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
createDirectory(path: string, name: string): Promise<string>
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.

View File

@@ -5,6 +5,7 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
@@ -12,15 +13,19 @@ import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type {
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
} from './contract/session-history.ts'
export type { ISessions } from './contract/sessions.ts'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
@@ -29,17 +34,28 @@ export type {
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'
export type {
DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
export type { SessionHistoryInspection } from './sessions/history.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
// Projection value store (session-projection RFC, push model): host-computed
@@ -118,6 +134,8 @@ declare module 'cordis' {
slots: import('./slots.ts').SlotsService
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** Read-only history sources isolated from Chat sessions and workspace state. */
sessionHistory: import('./contract/session-history.ts').ISessionHistory
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
@@ -133,24 +151,55 @@ export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const sessionHistory = new SessionHistoryService(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) },
onMuxEnvelope: (envelope) => {
sessions.handleMuxEnvelope(envelope)
try {
sessionHistory.handleMuxEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history frame routing failed:', error)
}
},
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')
try {
sessionHistory.handleHostEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history host-frame routing failed:', error)
}
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
ctx.emit('connection/reset')
try {
sessionHistory.handleConnected()
} catch (error) {
console.error('[web-runtime] history reconnect failed:', error)
}
},
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
// (reconnect replays flow from stream open, ahead of onConnected):
// the only safe moment to drop generation-scoped interaction state.
if (state === 'reconnecting') {
sessions.handleDisconnected()
try {
sessionHistory.handleDisconnected()
} catch (error) {
console.error('[web-runtime] history disconnect failed:', error)
}
}
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')

View File

@@ -0,0 +1,472 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type {
HistoryEntry, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} from '../sessions/conversation-context.ts'
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
interface CallIndexEntry {
name: string
argsRaw: string
time: number
callView: ToolCallView | null
}
interface FoldedContext {
generation: number
nodes: readonly number[]
originSeq?: number
}
interface AssistantStepMetadata {
stepStartTime: number | null
firstTokenTime: number | null
}
/** Immutable conversation projections derived only from the history source. */
export interface ConversationHistoryProjection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
// Trajectory owns surface-window reconstruction so its immutable ledger does
// not depend on Chat's live fold adapter or Session's mutable state.
/* jscpd:ignore-start */
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/* jscpd:ignore-end */
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
if (event?.type !== 'user/message') return 'rewrite'
const source = event.data.source
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
if (source.plugin === 'compact') return 'compaction'
if (source.plugin === 'rewind') return 'rewind'
}
return 'rewrite'
}
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const surface = new SurfaceManager(replay)
const contexts: FoldedContext[] = []
let generation = 0
let originSeq: number | undefined
for (const event of events) {
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
contexts.push({
generation,
nodes: [...surface.nodes],
...(originSeq === undefined ? {} : { originSeq }),
})
generation++
originSeq = event.seq
}
replay.push(event)
}
contexts.push({
generation,
nodes: [...surface.nodes],
...(originSeq === undefined ? {} : { originSeq }),
})
return contexts
}
// History projection owns its node mapping so Chat's live adapter remains free
// of inspection metadata and lifecycle coupling.
/* jscpd:ignore-start */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
assistantTiming: AssistantTiming | undefined,
requestConfig: AssistantRequestConfig | undefined,
): ConversationNode {
switch (event.type) {
case 'user/message':
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
provenance: {
provider: event.data.message.source.provider,
model: event.data.message.source.model,
},
...(requestConfig === undefined ? {} : { requestConfig }),
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
callTime: call?.time ?? null,
content: result.content, isError: result.isError === true,
...(event.data.error === undefined ? {} : { error: event.data.error }),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
}
}
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/* jscpd:ignore-end */
function projectTransient(entries: readonly HistoryEntry[]): Pick<
ConversationHistoryProjection,
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
> {
let partial: PartialAccumulator | null = null
const openCalls = new Map<string, RunningToolCall>()
const interruptedNodes: ConversationNode[] = []
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
for (const entry of entries) {
const { event } = entry
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
// The independent replay emits the same public running-call shape as
// Chat without reading or mutating Session's live index.
/* jscpd:ignore-start */
codeDispatches.set(data.parentCallId, [...siblings, {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
}])
/* jscpd:ignore-end */
continue
}
if ((event.type as string) === 'tool/code-dispatch') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
// History independently reproduces the public settled-call shape instead
// of consuming Session's live code-dispatch projection.
/* jscpd:ignore-start */
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
}
codeDispatches.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
/* jscpd:ignore-end */
continue
}
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (partial === null || partial.turn !== turn || partial.step !== step) {
partial = new PartialAccumulator(turn, step)
}
partial.push(chunk)
break
}
case 'assistant/message':
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
break
case 'tool/call':
// History reconstructs its own in-flight index; this intentionally
// mirrors the published Chat node shape, not Chat's mutable state.
/* jscpd:ignore-start */
openCalls.set(String(event.data.callId), {
callId: String(event.data.callId),
name: event.data.name,
argsRaw: event.data.arguments,
turn: event.data.turn,
step: event.data.step,
time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null,
})
/* jscpd:ignore-end */
break
case 'tool/result':
openCalls.delete(String(event.data.message.source.callId))
break
case 'turn/end': {
if (partial !== null && partial.turn === event.data.turn) {
const { blocks } = partial.toPartial()
const visible = blocks.some(block =>
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
if (visible) {
interruptedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: partial.turn, step: partial.step, blocks, interrupted: true,
})
}
partial = null
}
let callOffset = 0
for (const [callId, call] of openCalls) {
if (call.turn !== event.data.turn) continue
openCalls.delete(callId)
// Interrupted terminal nodes are reconstructed independently so a
// Trajectory replay cannot observe Session's frozen-node lifecycle.
/* jscpd:ignore-start */
interruptedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView,
resultView: null,
})
/* jscpd:ignore-end */
}
break
}
default:
break
}
}
return {
interruptedNodes,
partial: partial?.toPartial() ?? null,
runningCalls: [...openCalls.values()],
codeDispatches,
}
}
/**
* Project one immutable history ledger without reading or mutating Chat state.
* @param entries - Contiguous history entries in sequence order.
* @returns Event order, context lineage, and transient tail state.
*/
export function projectConversationHistory(
entries: readonly HistoryEntry[],
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const baseSeq = events[0]?.seq ?? 0
const padded = [
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
...events,
]
const callIndex = new Map<string, CallIndexEntry>()
const resultViews = new Map<number, ToolResultView>()
const assistantSteps = new Map<string, AssistantStepMetadata>()
const assistantTimings = new Map<number, AssistantTiming>()
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
let activeRequestConfig: AssistantRequestConfig | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let contextGeneration = 0
for (const [index, event] of events.entries()) {
const view = entries[index]?.view
if (event.type === 'tool/call') {
callIndex.set(String(event.data.callId), {
name: event.data.name,
argsRaw: event.data.arguments,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
} else if (event.type === 'tool/result' && view?.for === 'result') {
resultViews.set(event.seq, view.view)
}
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
contextGeneration++
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
}
if (event.type === 'request/header') {
activeRequestConfig = event.data.header.config
activePrompt = {
config: event.data.header.config,
system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [],
}
promptsByContext.set(contextGeneration, activePrompt)
} else if (event.type === 'step/start') {
assistantSteps.set(
assistantStepKey(event.data.turn, event.data.step),
{ stepStartTime: event.time, firstTokenTime: null },
)
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
const key = assistantStepKey(event.data.turn, event.data.step)
const current = assistantSteps.get(key) ?? {
stepStartTime: null,
firstTokenTime: null,
}
if (current.firstTokenTime === null) {
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
}
} else if (event.type === 'assistant/message') {
assistantTimings.set(
event.seq,
{
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
stepStartTime: null,
firstTokenTime: null,
}),
completedTime: event.time,
},
)
if (activeRequestConfig !== undefined) {
assistantRequestConfigs.set(event.seq, activeRequestConfig)
}
}
}
const nodeCache = new Map<number, ConversationNode>()
const materialize = (seq: number): ConversationNode | undefined => {
const cached = nodeCache.get(seq)
if (cached !== undefined) return cached
const event = padded[seq]
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
callIndex,
resultViews.get(seq) ?? null,
assistantTimings.get(seq),
assistantRequestConfigs.get(seq),
)
nodeCache.set(seq, node)
return node
}
const eventNodes = events.flatMap((event) => {
const node = materialize(event.seq)
return node === undefined ? [] : [node]
})
let contexts: readonly ConversationContext[]
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
contexts = [{
id: 0,
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
nodes: eventNodes,
}]
} else {
try {
contexts = foldContexts(padded).map((context): ConversationContext => {
const nodes = context.nodes.flatMap((seq) => {
const node = materialize(seq)
return node === undefined ? [] : [node]
})
const prompt = promptsByContext.get(context.generation)
if (context.originSeq === undefined) {
return {
id: context.generation,
...(prompt === undefined ? {} : { prompt }),
nodes,
}
}
const originEvent = padded[context.originSeq]
return {
id: context.generation,
parentId: context.generation - 1,
origin: contextOriginKind(originEvent),
originSeq: context.originSeq,
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
...(prompt === undefined ? {} : { prompt }),
nodes,
}
})
} catch (error) {
console.error('[web-runtime] history surface fold failed, using event order:', error)
contexts = [{
id: 0,
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
nodes: eventNodes,
}]
}
}
return {
eventNodes,
contexts,
...projectTransient(entries),
}
}

View File

@@ -0,0 +1,66 @@
import type { Context } from 'cordis'
import type {
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
ISessionHistory, SessionHistoryFace,
} from '../contract/session-history.ts'
import { SessionHistorySource } from './source.ts'
/** Root registry and frame router for independent inspection histories. */
export class SessionHistoryService implements ISessionHistory {
private readonly sources = new Map<SessionId, SessionHistorySource>()
/**
* @param ctx - Client root context.
* @param api - Shared wire client.
*/
constructor(ctx: Context, private readonly api: IApiClient) {
ctx.reflect.provide('sessionHistory', this, undefined)
}
/**
* Resolve one identity-stable history source.
* @param sessionId - Host session identity.
* @returns Source independent from SessionManager.
*/
source(sessionId: SessionId): SessionHistoryFace {
let source = this.sources.get(sessionId)
if (source === undefined) {
source = new SessionHistorySource(sessionId, this.api)
this.sources.set(sessionId, source)
}
return source
}
/**
* Route history-relevant mux frames only to an existing source.
* @param envelope - Validated mux envelope.
*/
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
}
/**
* Drop a removed session's independent history source.
* @param envelope - Validated host envelope.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
const frame = envelope.payload
if (frame.type !== 'host/session-removed') return
this.sources.get(frame.sessionId)?.dispose()
this.sources.delete(frame.sessionId)
}
/** Invalidate requests from the dead connection generation. */
handleDisconnected(): void {
for (const source of this.sources.values()) source.handleDisconnected()
}
/** Rebuild every previously activated source from the new generation. */
handleConnected(): void {
for (const source of this.sources.values()) source.resync()
}
}

View File

@@ -0,0 +1,352 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
const HISTORY_PAGE_MESSAGES = 50
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
}
/** Independent raw-history owner used only by inspection consumers. */
export class SessionHistorySource implements SessionHistoryFace {
private entries: readonly HistoryEntry[] = []
private baseSeq = 0
private hasMore = false
private state: SessionHistorySnapshot['state'] = 'cold'
private error: RpcError | null = null
private generation = 0
private persistentConsumer = false
private readonly consumerSignals = new Set<AbortSignal>()
private openPromise: Promise<void> | null = null
private olderPromise: Promise<void> | null = null
private stitching = false
private liveBuffer: HistoryEntry[] = []
private subscribedLastSeq: number | null = null
private inspectionCache: {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* @param sessionId - Host session identity.
* @param api - Shared wire client.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Subscribe to ledger changes.
* @param listener - Change callback.
* @returns Unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached ledger snapshot.
* @returns Stable snapshot until the source changes.
*/
getSnapshot(): SessionHistorySnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
/**
* Load the tail and exhaust all available older pages.
* @param signal - Consumer lifetime.
* @returns When paging completes, fails to advance, or is aborted.
*/
async loadAll(signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) return
this.trackConsumer(signal)
await this.open()
while (
!isAborted(signal)
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
}
}
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
private async loadForConsumers(): Promise<void> {
await this.open()
while (
this.hasConsumer()
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
}
}
/**
* Route a relevant mux frame without involving the Chat session.
* @param frame - Session-addressed frame.
*/
handleMuxFrame(frame: MuxFrame): void {
if (frame.type === 'session/subscribed') {
this.subscribedLastSeq = frame.lastSeq
return
}
if (frame.type !== 'session/event') return
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
}
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
handleDisconnected(): void {
this.generation++
this.openPromise = null
this.olderPromise = null
this.stitching = false
this.liveBuffer = []
this.subscribedLastSeq = null
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.notifier.markDirty()
}
}
/** Rebuild an activated ledger from the new connection generation. */
resync(): void {
if (!this.hasConsumer()) return
this.generation++
this.openPromise = null
this.olderPromise = null
this.stitching = false
this.liveBuffer = []
this.subscribedLastSeq = null
this.entries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
this.error = null
this.notifier.markDirty()
void this.loadForConsumers()
}
/** Stop future refresh work after the host removes the session. */
dispose(): void {
this.persistentConsumer = false
this.consumerSignals.clear()
this.generation++
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
}
private open(): Promise<void> {
if (this.state === 'ready') return Promise.resolve()
if (this.openPromise !== null) return this.openPromise
const generation = this.generation
const operation = this.doOpen(generation)
const settled = operation.finally(() => {
if (this.openPromise === settled) this.openPromise = null
})
this.openPromise = settled
return settled
}
private trackConsumer(signal: AbortSignal | undefined): void {
if (signal === undefined) {
this.persistentConsumer = true
return
}
if (this.consumerSignals.has(signal)) return
this.consumerSignals.add(signal)
signal.addEventListener('abort', () => {
this.consumerSignals.delete(signal)
}, { once: true })
}
private hasConsumer(): boolean {
return this.persistentConsumer || this.consumerSignals.size > 0
}
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (generation !== this.generation) return
if (!result.ok) {
this.state = 'error'
this.error = result.error
return
}
this.installTail(result.value.events, result.value.hasMore, true)
const tailSeq = this.tailSeq()
if (
this.subscribedLastSeq !== null
&& tailSeq !== null
&& this.subscribedLastSeq > tailSeq
) {
result = (await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})).result
if (generation !== this.generation) return
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
}
this.state = 'ready'
} catch (error) {
if (generation !== this.generation) return
this.state = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.notifier.markDirty()
}
}
private loadOlder(): Promise<void> {
if (this.olderPromise !== null) return this.olderPromise
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
const generation = this.generation
const operation = (async () => {
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId,
beforeSeq: this.baseSeq,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
const older = result.value.events
if (older.length === 0) {
this.hasMore = result.value.hasMore
return
}
const tail = older.at(-1)
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
console.error(
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
)
this.hasMore = false
return
}
this.entries = [...older, ...this.entries]
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
} catch (error) {
console.error('[web-runtime] inspection history paging failed:', error)
}
})()
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.notifier.markDirty()
})
this.olderPromise = settled
return settled
}
private installTail(
tail: readonly HistoryEntry[],
hasMore: boolean,
replace: boolean,
): void {
if (replace) {
this.entries = [...tail]
this.hasMore = hasMore
} else {
const firstSeq = tail[0]?.event.seq
const prefix = firstSeq === undefined
? this.entries
: this.entries.filter(entry => entry.event.seq < firstSeq)
this.entries = [...prefix, ...tail]
}
this.baseSeq = this.entries[0]?.event.seq ?? 0
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty()
}
private acceptLive(entry: HistoryEntry): void {
if (this.state === 'loading' || this.stitching) {
this.liveBuffer.push(entry)
return
}
if (this.state !== 'ready') return
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
this.liveBuffer.push(entry)
void this.repairGap()
return
}
this.appendLive(entry)
this.notifier.markDirty()
}
private appendLive(entry: HistoryEntry): void {
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq <= tailSeq) return
this.entries = [...this.entries, entry]
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
const generation = this.generation
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (result.ok && generation === this.generation && this.state === 'ready') {
this.installTail(result.value.events, result.value.hasMore, false)
}
} catch (error) {
console.error('[web-runtime] inspection history gap repair failed:', error)
} finally {
if (generation === this.generation) this.stitching = false
}
}
private tailSeq(): number | null {
return this.entries.at(-1)?.event.seq ?? null
}
private buildSnapshot(): SessionHistorySnapshot {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
this.inspectionCache = {
entries,
value: createHistoryInspection(() => entries),
}
}
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.inspectionCache.value,
}
}
}

View File

@@ -0,0 +1,23 @@
import type { ConversationNode } from './conversation.ts'
import type { ConversationPromptSnapshot } from './request-inspection.ts'
/** Operation that started a new append-only model context. */
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
/** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */
id: number
/** Previous generation in this session; absent for the initial context. */
parentId?: number
/** Why this generation exists; absent for the initial context. */
origin?: ConversationContextOriginKind
/** Event seq of the replacement that created this generation. */
originSeq?: number
/** Unix epoch ms of the replacement that created this generation. */
createdAt?: number
/** Latest request header observed in this generation, inherited until a later header replaces it. */
prompt?: ConversationPromptSnapshot
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
nodes: readonly ConversationNode[]
}

View File

@@ -7,12 +7,29 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
RpcError, SessionId, ToolCallView, ToolResultView,
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
export interface AssistantRequestConfig {
provider: string
model: string
purpose?: string
thinking?: string
reasoningEffort?: string
temperature?: number
maxTokens?: number
stop?: readonly string[]
}
/** Stable provider/model identity reported for one completed request. */
export interface AssistantProvenanceView {
provider: string
model: string
}
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -54,6 +71,16 @@ export interface UserMessageNode {
source: unknown
}
/** Recorded boundaries used to derive assistant latency and throughput. */
export interface AssistantTiming {
/** Matching step/start timestamp, or null when it is outside the current event window. */
stepStartTime: number | null
/** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
firstTokenTime: number | null
/** Final assistant/message timestamp. */
completedTime: number
}
/** A finalized (or interruption-frozen) assistant message. */
export interface AssistantMessageNode {
kind: 'assistant'
@@ -64,6 +91,10 @@ export interface AssistantMessageNode {
step: number
blocks: readonly AssistantBlock[]
usage?: unknown
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
/** Timing derived from the recorded step/chunk/message event sequence. */
timing?: AssistantTiming
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
interrupted?: true
@@ -185,10 +216,12 @@ export interface RunningToolCall {
}
/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */
/** One independently addressable row from the transient queue snapshot. */
export interface QueuedMessage {
readonly key: string
readonly id: InboxItemId
readonly preview: string
/** Complete editable text; null when the message contains non-text blocks. */
readonly text: string | null
}
/** In-progress assistant output (chunk accumulator product). */
@@ -246,7 +279,7 @@ export interface ConversationSnapshot {
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
queue: readonly QueuedMessage[]
running: boolean
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */

View File

@@ -7,7 +7,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, ConversationNode } from './conversation.ts'
@@ -33,6 +35,11 @@ function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
@@ -137,7 +144,7 @@ export class FoldAdapter {
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = false
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
@@ -160,6 +167,7 @@ export class FoldAdapter {
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
this.padded.push(event)
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.indexCall(event, view)
this.indexCommand(event)
}

View File

@@ -0,0 +1,66 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type {
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
} from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
* the entries and replays event order and request lifecycle state.
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
* @returns Lazy, memoized inspection fields for that exact window.
*/
export function createHistoryInspection(
loadEntries: () => readonly HistoryEntry[],
): SessionHistoryInspection {
let entries: readonly HistoryEntry[] | undefined
let conversation: ReturnType<typeof projectConversationHistory> | undefined
let requests: ReturnType<typeof inspectRequests> | undefined
const historyEntries = () => entries ??= loadEntries()
const conversationProjection = () =>
conversation ??= projectConversationHistory(historyEntries())
const requestProjection = () =>
requests ??= inspectRequests(historyEntries())
return {
get eventNodes() {
return conversationProjection().eventNodes
},
get contexts() {
return conversationProjection().contexts
},
get interruptedNodes() {
return conversationProjection().interruptedNodes
},
get partial() {
return conversationProjection().partial
},
get runningCalls() {
return conversationProjection().runningCalls
},
get codeDispatches() {
return conversationProjection().codeDispatches
},
get requests() {
return requestProjection().requests
},
get callSchemas() {
return requestProjection().callSchemas
},
}
}

View File

@@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary {
title?: string
}
/** One flattened session-list row (summary + lineage indent depth). */
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
export interface SessionListEntry {
sessionId: SessionId
title?: string
@@ -19,6 +19,8 @@ export interface SessionListEntry {
blank: boolean
parentSessionId?: SessionId
cwd?: string
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -28,9 +30,10 @@ export interface SessionListEntry {
* follows the established input order; this projection never re-sorts a
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
return
}
visited.add(s.sessionId)
out.push({ ...s, depth })
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
for (const kid of kids) walk(kid, depth + 1)

View File

@@ -57,6 +57,11 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
* replays of the same requested frame). Manager-owned rather than read off Session instances
* because the sidebar must light up for sessions never instantiated. Cleared per connection
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -346,23 +351,39 @@ export class SessionManager {
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
this.notifier.markDirty()
// New mux-generation baseline: buffered session/queued frames belong to
// New mux-generation baseline: buffered session/queue frames belong to
// the previous generation and the host is about to resend the live
// snapshot — drop them, or every reconnect appends a duplicate batch
// (and enough reconnects push real approval/question frames past the
// cap). Same re-baseline signal Session uses for its own mirror.
const buffered = this.pendingBuffers.get(frame.sessionId)
if (buffered !== undefined) {
const kept = buffered.filter(item => item.payload.type !== 'session/queued')
const kept = buffered.filter(item => item.payload.type !== 'session/queue')
if (kept.length !== buffered.length) {
if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId)
else this.pendingBuffers.set(frame.sessionId, kept)
}
}
}
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
// every session, instantiated or not; approvalId keys make replays idempotent.
if (frame.type === 'approval/requested') {
let ids = this.waitingApprovals.get(frame.sessionId)
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
if (!ids.has(frame.approvalId)) {
ids.add(frame.approvalId)
this.notifier.markDirty()
}
} else if (frame.type === 'approval/resolved') {
const ids = this.waitingApprovals.get(frame.sessionId)
if (ids !== undefined && ids.delete(frame.approvalId)) {
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
this.notifier.markDirty()
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question/queued frames never hit history: buffer for replay on
// Approval/question/queue frames never hit history: buffer for replay on
// instantiation; everything else drops (not instantiated — history fully
// backfills on open).
switch (frame.type) {
@@ -370,8 +391,12 @@ export class SessionManager {
case 'approval/resolved':
case 'question/requested':
case 'question/resolved':
case 'session/queued': {
case 'session/queue': {
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
const prior = frame.type === 'session/queue'
? buffer.findIndex(item => item.payload.type === 'session/queue')
: -1
if (prior !== -1) buffer.splice(prior, 1)
buffer.push(envelope)
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
this.pendingBuffers.set(frame.sessionId, buffer)
@@ -404,6 +429,7 @@ export class SessionManager {
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
return
}
@@ -421,6 +447,30 @@ export class SessionManager {
}
}
/**
* The moment a connection generation dies (before any next-generation frame
* can arrive — onConnected waits for the readiness handshake while replayed
* frames flow from stream open, so clearing there would race the replay):
* drop generation-scoped live state. Approvals resolved while disconnected
* send no frame, so the stale bits and the buffered answerable frames must
* not survive into the next generation — the mux-open replay re-adds every
* still-pending question with its live rpcId.
*/
handleDisconnected(): void {
if (this.waitingApprovals.size > 0) {
this.waitingApprovals.clear()
this.notifier.markDirty()
}
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
const kept = buffer.filter(item =>
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
if (kept.length === buffer.length) continue
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
else this.pendingBuffers.set(sessionId, kept)
}
}
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
void this.refreshList()
@@ -436,7 +486,7 @@ export class SessionManager {
? { ...summary, title }
: summary
})
const fresh = flattenLineage(merged)
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
@@ -444,6 +494,7 @@ export class SessionManager {
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -0,0 +1,401 @@
// Request-centric inspection read model. Ordinary generation and compaction
// calls share one chronological projection; presentation-specific grouping
// remains in the trajectory consumer.
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
/** Complete model-visible request header in force for an ordinary generation. */
export interface ConversationPromptSnapshot {
/** Provider/model and sampling configuration from the effective request header. */
config: AssistantRequestConfig
/** Rendered system prompt text; empty when the request had no system prompt. */
system: string
/** Complete tool catalog sent with the request, including tools that were never called. */
tools: readonly ToolSchema[]
}
/** System/tool change introduced while preparing one ordinary request. */
export interface RequestPromptChange {
/** Sequence of the request/header event that introduced this state. */
seq: number
/** Unix epoch ms from the request/header event. */
time: number
/** How the model-visible prompt differs from the previous recorded state. */
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
/** State immediately before this change; absent for the initial header. */
previous?: ConversationPromptSnapshot
}
/** One provider request reconstructed from durable request lifecycle events. */
export interface RequestView {
/** Request category; compaction is a purpose, not a separate projection. */
purpose: 'assistant' | 'compaction'
/** Sequence that opened the operation represented by this request. */
startSeq: number
turn: number
/** Agent-loop step, or zero for a direct compaction request. */
step: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Effective ordinary request input, inherited until a later header changes it. */
prompt?: ConversationPromptSnapshot
/** Prompt change logged while preparing this request. */
promptChange?: RequestPromptChange
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Assistant message or compaction summary sequence produced by this request. */
resultSeq?: number
/** Compaction replacement message sequence, when one was committed. */
replacementSeq?: number
/** Safe compaction summary projection. */
summary?: readonly ContentBlock[]
/** Complete compaction provider output before the safe projection. */
rawOutput?: readonly ContentBlock[]
/** Retry ordinal scheduled after a failed ordinary request. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** Immutable request-centric projection derived from one history window. */
export interface RequestInspectionSnapshot {
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
}
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
export function inspectRequests(
entries: readonly HistoryEntry[],
): RequestInspectionSnapshot {
const events = entries.map(entry => entry.event)
return {
requests: deriveRequests(events),
callSchemas: deriveCallSchemas(events),
}
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
}
interface CompactionSummaryEvent {
type: 'compact/summary'
seq: number
time: number
data: {
summary: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: unknown
}
}
interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number; error?: string }
}
function requestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
const previous = current as TokenUsage | undefined
return {
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
? {}
: {
cacheReadTokens:
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
}),
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
? {}
: {
cacheWriteTokens:
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
}),
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: {
reasoningTokens:
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
}),
}
}
function deriveCallSchemas(
events: readonly SessionEvent[],
): ReadonlyMap<string, ToolSchema> {
let active = new Map<string, ToolSchema>()
const calls = new Map<string, ToolSchema>()
const capture = (callId: string, name: string): void => {
if (calls.has(callId)) return
const schema = active.get(name)
if (schema !== undefined) calls.set(callId, schema)
}
for (const event of events) {
if (event.type === 'request/header') {
const tools: unknown = event.data.header.tools
active = new Map(
Array.isArray(tools)
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
: [],
)
continue
}
if (event.type === 'tool/call') {
capture(String(event.data.callId), event.data.name)
continue
}
const type = event.type as string
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
const data = event.data as unknown as { subCallId: string; name: string }
capture(data.subCallId, data.name)
}
}
return calls
}
function promptChange(
previous: ConversationPromptSnapshot | undefined,
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous !== undefined && !systemChanged && !toolsChanged) return
return {
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
...(previous === undefined ? {} : { previous }),
}
}
/** Project ordinary and compaction provider calls into one chronological request stream. */
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
const requests: RequestView[] = []
const ordinaryByStep = new Map<string, number>()
let activeStep: string | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
const update = (index: number | undefined, change: Partial<RequestView>): void => {
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = requestKey(turn, step)
ordinaryByStep.set(key, requests.length)
requests.push({
purpose: 'assistant',
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activePrompt === undefined
? {}
: { prompt: activePrompt, requestConfig: activePrompt.config }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'request/header') {
const tools: unknown = sourceEvent.data.header.tools
const prompt: ConversationPromptSnapshot = {
config: sourceEvent.data.header.config,
system: sourceEvent.data.header.system ?? '',
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
}
const change = promptChange(activePrompt, prompt, sourceEvent)
activePrompt = prompt
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
prompt,
requestConfig: prompt.config,
...(change === undefined ? {} : { promptChange: change }),
})
continue
}
if (
sourceEvent.type === 'assistant/chunk'
&& sourceEvent.data.chunk.type === 'usage'
) {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
})
continue
}
if (sourceEvent.type === 'assistant/message') {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.message.source.provider,
model: sourceEvent.data.message.source.model,
},
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
? {}
: { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = ordinaryByStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request?.status === 'running') {
update(index, {
completedAt: sourceEvent.time,
status: 'error',
})
}
if (activeStep === key) activeStep = undefined
continue
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
status: 'error',
error: event.data.failure.message,
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
})
continue
}
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
const reason = sourceEvent.data.reason
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
})
continue
}
const type = sourceEvent.type as string
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
activeCompaction = requests.length
requests.push({
purpose: 'compaction',
startSeq: event.seq,
turn: event.data.turn,
step: 0,
startedAt: event.time,
completedAt: null,
status: 'running',
})
continue
}
if (type === 'compact/summary' && activeCompaction !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
update(activeCompaction, {
resultSeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
provenance: {
provider: event.data.provider,
model: event.data.model,
},
requestConfig: {
provider: event.data.provider,
model: event.data.model,
purpose: 'compaction',
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
},
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
})
continue
}
if (
sourceEvent.type === 'user/message'
&& activeCompaction !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
update(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (type !== 'compact/end' || activeCompaction === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
update(activeCompaction, {
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
})
activeCompaction = undefined
}
return requests.sort((left, right) => left.startSeq - right.startSeq)
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}

View File

@@ -40,6 +40,8 @@ export interface SessionSummary {
cwd?: string
parentId?: SessionId
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
@@ -292,6 +294,11 @@ export class SessionsService implements ISessions {
this.manager.handleConnected()
}
/** Drop generation-scoped live interaction state the moment a connection generation dies. */
handleDisconnected(): void {
this.manager.handleDisconnected()
}
/**
* Create a session on the host. Resolution guarantee: by the time the
* promise resolves, the created session is in the list store and
@@ -463,6 +470,7 @@ export class SessionsService implements ISessions {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
waitingApproval: entry.waitingApproval,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),

View File

@@ -4,8 +4,8 @@ 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, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResult, SessionId, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -48,14 +48,6 @@ export interface SessionOptions {
/** 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
@@ -65,6 +57,12 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}` : flat
}
/** Recover complete composer text only when editing cannot discard non-text blocks. */
function queueTextOf(content: readonly ContentBlock[]): string | null {
if (!content.every(block => block.type === 'text')) return null
return content.map(block => block.text).join('')
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer. Features see only
@@ -102,9 +100,8 @@ export class Session implements SessionFace {
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[] = []
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
@@ -234,6 +231,15 @@ export class Session implements SessionFace {
return result
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
try {
return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result
} catch (error) {
return transportError(error)
}
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* @returns the cancel result.
@@ -252,6 +258,40 @@ export class Session implements SessionFace {
return result
}
/**
* Rename: contract session.rename 1:1. On success settle the 'title'
* projection cell from the response's `{title, seq}` under the store's
* higher-seq-wins rule (the push frame arriving later is a no-op replay),
* so the list row and any useProjection('title') reader update without
* waiting for the mux frame.
* @param title - raw title text (the host normalizes acceptance).
* @returns the rename result (normalized accepted title + title event seq).
*/
async rename(title: string): Promise<RpcResult<{ title: string; seq: number }>> {
try {
const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title })
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
return result
} catch (error) {
return transportError(error)
}
}
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle;
* outcomes render as flow nodes, never as a response echo).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
*/
async command(line: string): Promise<RpcResult<{ matched: boolean }>> {
try {
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result
} catch (error) {
return transportError(error)
}
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
@@ -359,20 +399,15 @@ export class Session implements SessionFace {
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': {
const message = frame.message
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
// provisional-echo reconciliation key); otherwise the frame envelope id.
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
this.queued.push({
row: { key, preview: queuePreviewOf(message.content) },
steering: frame.steering,
sourceJson: JSON.stringify(message.source),
})
case 'session/queue': {
this.queued = frame.items.map(item => ({
id: item.id,
preview: queuePreviewOf(item.message.content),
text: queueTextOf(item.message.content),
}))
this.queueRev++
this.notifier.markDirty()
return
@@ -425,15 +460,6 @@ export class Session implements SessionFace {
* @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) {
@@ -598,27 +624,6 @@ export class Session implements SessionFace {
}
}
/** 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.message.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 {
@@ -798,7 +803,7 @@ export class Session implements SessionFace {
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) }
this.queueCache = { rev: this.queueRev, value: this.queued }
}
const partial = this.partial?.toPartial() ?? null
return {
@@ -812,7 +817,10 @@ export class Session implements SessionFace {
queue: this.queueCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
// the host's no-turn sessionBlank predicate).
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
@@ -833,7 +841,9 @@ export class Session implements SessionFace {
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank → engaging → active never steps back; a failed first
* prompt stays engaging (retry semantics — see ComposerPhase).
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
* @param hasContent - any conversation material exists (non-command nodes,
* partial, running turn, pending waits; command lifecycle rows alone keep
* the session blank).
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

@@ -9,7 +9,7 @@
* with the last holding entry, session instances cleared (with persisted
* state) on scope death.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
@@ -18,7 +18,7 @@ import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type {
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -70,6 +70,8 @@ interface ErasedRegisterOptions {
select?: (owner: never) => unknown
/** Chain-slot explicit ordering override (ascending; registration order otherwise). */
priority?: number
/** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */
locale?: string
registrant?: string
}
@@ -82,6 +84,7 @@ export class SlotsService extends Service {
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
private _renderer: SlotRenderer | undefined
private _locale: LocaleFace | undefined
private _host: SlotRendererHost | undefined
/**
@@ -127,6 +130,23 @@ export class SlotsService extends Service {
}, 'slots.install()')
}
/**
* Install the locale face backing the `t` standard seat (the locale
* plugin's product; same boot-once discipline as the renderer install).
* Runs through the caller's ctx.effect, so the installing fiber's unload
* uninstalls the face.
* @param face - namespace binder + revision observable.
*/
installLocale(face: LocaleFace): void {
if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)')
this.ctx.effect(() => {
this._locale = face
return () => {
if (this._locale === face) this._locale = undefined
}
}, 'slots.installLocale()')
}
/**
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
@@ -246,6 +266,12 @@ export class SlotsService extends Service {
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// `locale` is a live getter: the face installs (and, under HMR, swaps)
// on the locale plugin's own fiber lifetime, while this host object is
// built once — a captured value would strand renders on a dead face. The
// alias is required: `this` inside the getter is the host literal.
// oxlint-disable-next-line typescript/no-this-alias
const service = this
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
@@ -259,6 +285,7 @@ export class SlotsService extends Service {
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
get locale() { return service._locale },
}
return this._host
}
@@ -310,6 +337,6 @@ export class SlotsService extends Service {
// The core's overloads proved the shares; the implementation works on
// the erased view (same pattern as the core's own implementation arm).
const options = rawOptions as ErasedRegisterOptions
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
}

View File

@@ -2,7 +2,8 @@
import type { Context } from 'cordis'
import type {
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
@@ -30,6 +31,14 @@ export class WorkspaceCreateError extends Error {
}
}
/** Structured browse failure so the directory browser can branch on Host business codes. */
export class DirectoryBrowseError extends Error {
constructor(readonly rpcError: RpcError) {
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
this.name = 'DirectoryBrowseError'
}
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService implements IWorkspaces {
/** UI-facing immutable projection; the manager remains wire truth. */
@@ -172,7 +181,7 @@ export class WorkspacesService implements IWorkspaces {
}
/**
* Open the Host's native directory picker.
* Open the Host's native directory picker (the `native` capability).
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
@@ -183,6 +192,30 @@ export class WorkspacesService implements IWorkspaces {
return response.result.value.path
}
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry.
*/
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value
}
/**
* Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
async createDirectory(path: string, name: string): Promise<string> {
const response = await this.api.host.createDirectory({ path, name })
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value.path
}
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */

View File

@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -80,7 +81,9 @@ export class FakeApiClient implements IApiClient {
Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
@@ -88,6 +91,18 @@ export class FakeApiClient implements IApiClient {
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -102,13 +117,17 @@ export class FakeApiClient implements IApiClient {
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}

View File

@@ -8,6 +8,7 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
@@ -27,6 +28,7 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes()
expect(adapter.nodes()).toBe(first)
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second.nodes).toHaveLength(3)
@@ -35,6 +37,52 @@ describe('FoldAdapter', () => {
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('materializes all six node variants with field mapping', () => {
const adapter = new FoldAdapter()
const events = [
@@ -114,6 +162,68 @@ describe('FoldAdapter', () => {
}
})
it('silently degrades when a replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
at(10, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 3 },
sourceEventSeqs: [1, 3],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
ev.user(11, 'newer message'),
], 10)
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a live replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([ev.user(10, 'window head')], 10)
adapter.append(at(11, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'live summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}))
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
@@ -130,6 +240,44 @@ describe('FoldAdapter', () => {
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)

View File

@@ -376,3 +376,62 @@ describe('connected generation', () => {
})
})
})
describe('waiting-approval list bit', () => {
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
// Mux-open replay of the same question (same approvalId) is idempotent.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
})
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
// Removed sessions drop their bit outright.
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(manager.getListSnapshot().items).toHaveLength(0)
})
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
// Generation death clears (resolved-while-disconnected questions send no frame)…
manager.handleDisconnected()
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
// …and a replayed frame arriving before onConnected (stream open precedes
// the readiness handshake) survives the later handleConnected untouched.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleConnected()
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
})
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
// Buffered pre-instantiation: an approval pair and a queued row.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
manager.handleDisconnected()
// Instantiate after the death sweep: no zombie interaction replays (the
// pendingBuffers held only dead-generation rpcIds), so the session mints
// no pending waits.
const session = manager.get(S1)
expect(session.getSnapshot().pending).toEqual([])
})
})

View File

@@ -1,32 +1,41 @@
/**
* 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.
* Queue snapshot semantics: authoritative replacement after every host-side
* change, reconnect re-baselining, pre-instantiation buffering, editable-text
* projection, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
InboxItemId, 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 text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
const rid = (id: string): RpcId => id as RpcId
const iid = (id: string): InboxItemId => id as InboxItemId
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
interface QueueFixture {
id: string
body: string
content?: ContentBlock[]
}
/** Build one authoritative queue snapshot. */
function queueFrame(items: QueueFixture[]): MuxFrame {
return {
type: 'session/queued',
type: 'session/queue',
sessionId: SID,
message: createUserMessage({
content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never,
}),
steering,
items: items.map(item => ({
id: iid(item.id),
message: createUserMessage({
content: item.content ?? text(item.body),
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
}),
})),
}
}
@@ -34,201 +43,131 @@ 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', () => {
describe('queue snapshot intake', () => {
it('projects stable ids, flat previews, and complete text', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1'))
const queue = session.getSnapshot().queue
expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }])
session.handleMuxEnvelope(rid('env-1'), queueFrame([
{ id: 'q-1', body: '第一条 排队\n消息' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
])
})
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
it('marks mixed-content messages non-editable while retaining their preview', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-2'), {
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' },
}),
steering: false,
})
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
session.handleMuxEnvelope(rid('env-2'), queueFrame([{
id: 'q-image',
body: '',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
}]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-image', preview: 'hi [image]', text: null },
])
})
it('caps the preview at 200 code points with an ellipsis', () => {
it('caps previews at 200 code points and preserves the full editable text', () => {
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)
const body = '长'.repeat(201)
session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }]))
const row = session.getSnapshot().queue[0]
expect(Array.from(row?.preview ?? '')).toHaveLength(201)
expect(row?.preview.endsWith('…')).toBe(true)
expect(row?.text).toBe(body)
})
it('replaces content, order, and membership from each authoritative frame', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-4'), queueFrame([
{ id: 'q-1', body: 'one' },
{ id: 'q-2', body: 'two' },
]))
session.handleMuxEnvelope(rid('env-5'), queueFrame([
{ id: 'q-2', body: 'two edited' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
])
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
expect(session.getSnapshot().queue).toEqual([])
})
it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s'))
session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }]))
const before = session.getSnapshot().queue
session.handleAgentError('unrelated') // dirties the snapshot without touching the queue
session.handleAgentError('unrelated')
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'])
})
describe('queue operation transport', () => {
it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue
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')) // idle → non-steering
session.handleMuxEnvelope(rid('e3'), 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,
message: createUserMessage({
content: text('loop'),
source: { kind: 'plugin', plugin: 'loop' },
}),
},
} as never
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
expect(session.getSnapshot().queue).toHaveLength(2)
const matchedSteering = {
seq: 1, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 0,
message: createUserMessage({
content: text('插话'),
source: { kind: 'user', rpcId: rid('p-2') },
}),
},
} as never
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
})
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'))
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([])
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
}])
expect(session.getSnapshot().queue).toBe(before)
})
})
describe('queue reconnect semantics', () => {
it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => {
it('session/subscribed clears stale state before the fresh snapshot lands', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old'))
// New mux generation: subscribed arrives first on the same stream...
session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }]))
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'])
session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }]))
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
})
it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => {
it('resync does not clear a baseline that raced ahead of the host connection signal', 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'))
session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }]))
await session.resync()
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
})
it('replayed steering retires without a replayed turn/start', () => {
it('running-status changes never guess at queue retirement', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true))
const committed = {
seq: 6, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 1,
message: createUserMessage({
content: text('重连插话'),
source: { kind: 'user', rpcId: rid('p-steer') },
}),
},
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
expect(session.getSnapshot().queue).toEqual([])
session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }]))
session.handleRunning(true)
session.handleRunning(false)
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
})
})
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)
describe('manager buffering of queue snapshots', () => {
it('replays only the latest snapshot for an uninstantiated session', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
})
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') })
it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
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') })
manager.handleMuxEnvelope({
rpcId: rid('g2a'),
payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 },
})
manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) })
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'])
expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
expect(snapshot.pending.map(pending => pending.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[] } } }
}

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
const at = (seq: number, type: string, data: unknown): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
events.map(event => ({ event }))
describe('inspectRequests', () => {
it('projects ordinary and compaction calls into one chronological request stream', () => {
const events = [
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'system',
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
at(3, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'done' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 5, outputTokens: 2 },
}),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'compact/start', { turn: 1 }),
at(6, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
rawOutput: [
{ type: 'reasoning', text: 'thought' },
{ type: 'text', text: 'summary' },
],
provider: 'fake',
model: 'compact-model',
usage: { inputTokens: 8, outputTokens: 3 },
}),
at(7, 'user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
})),
at(8, 'compact/end', { turn: 1 }),
]
const snapshot = inspectRequests(entriesOf(events))
expect(snapshot.requests).toMatchObject([
{
purpose: 'assistant',
startSeq: 0,
resultSeq: 3,
status: 'complete',
prompt: {
config: { provider: 'fake', model: 'model' },
system: 'system',
},
promptChange: { seq: 1, kind: 'initial' },
},
{
purpose: 'compaction',
startSeq: 5,
resultSeq: 6,
replacementSeq: 7,
status: 'complete',
summary: [{ type: 'text', text: 'summary' }],
},
])
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(1, 'tool/code-dispatch-start', {
parentCallId: 'parent',
subCallId: 'nested',
name: 'read',
arguments: {},
}),
]))
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
})
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
const retryUsage = {
inputTokens: 5,
outputTokens: 2,
cacheReadTokens: 8,
reasoningTokens: 1,
}
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: chunkUsage },
}),
at(2, 'llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 100,
failure: { message: 'rate limited' },
}),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: retryUsage },
}),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'recovered' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 1, outputTokens: 1 },
}),
]))
expect(snapshot.requests[0]).toMatchObject({
status: 'complete',
usage: {
inputTokens: 26,
outputTokens: 5,
cacheReadTokens: 8,
reasoningTokens: 1,
},
})
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: '{{tools}}',
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
]))
expect(snapshot.callSchemas).toEqual(new Map())
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
})
})

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('SessionHistorySource', () => {
it('loads every older page without changing a Chat session', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
plainTurn(12, 2, '最新问', '最新答'),
]
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
return histResponse(pages[0]!, false)
}
const source = new SessionHistorySource(SID, api)
await source.loadAll()
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
})
it('pins a lazy inspection to the entries in its source snapshot', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const before = source.getSnapshot()
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.user(6, 'later'),
})
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 6])
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
: Promise.resolve(err({
code: 'internal',
message: 'page unavailable',
details: {},
}))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('observes consumer cancellation between older pages', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) {
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
}
olderStarted.resolve(undefined)
return middle.promise
}
const source = new SessionHistorySource(SID, api)
const controller = new AbortController()
const complete = source.loadAll(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
hasMore: true,
}))
await complete
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
})

View File

@@ -126,6 +126,21 @@ describe('live event path', () => {
})
})
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
// A fresh session whose only window content is a command pair (plus the
// knob events a /permission switch appends — not surface-eligible, so
// they never become nodes) stays phase 'blank': selecting a preset from
// the hero must not enter the conversation view.
const { session } = await opened([])
expect(session.getSnapshot().composerPhase).toBe('blank')
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
const snapshot = session.getSnapshot()
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
expect(snapshot.composerPhase).toBe('blank')
})
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -286,6 +301,32 @@ describe('prompt and cancel errors', () => {
})
})
describe('rename', () => {
it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
const result = await session.rename(' 正名 ')
expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
// A stale lower-seq apply (the push-frame path routes into this same
// store) must not roll the settled value back.
session.projections.apply('title', '旧名', 3)
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
})
it('returns the business error untouched and folds a transport throw to internal', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
const rejected = await session.rename(' ')
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
api.onRename = () => Promise.reject(new Error('rename transport down'))
const folded = await session.rename('x')
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
})
})
describe('pending interactions', () => {
it('adds approval/question on requested and removes them on resolved', async () => {
const { session } = makeSession()
@@ -686,6 +727,7 @@ describe('resync', () => {
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
})
describe('run_code sub-dispatch indexing', () => {
@@ -799,20 +841,23 @@ describe('reference stability (the memo contract)', () => {
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, 'c1', 'echo', '{}'))
feed(ev.stepStart(7, 1))
feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
const before = session.getSnapshot()
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '与工具无关的流式'))
// A chunk storm touches partial/nodes only: unrelated projections keep identity.
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '与工具无关的流式'))
const after = session.getSnapshot()
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()
expect(resolved.runningCalls).not.toBe(after.runningCalls)
expect(resolved.pending).toBe(after.pending)
feed(ev.assistant(12, 1, '完成'))
expect(session.getSnapshot()).not.toBe(resolved)
})
})

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -234,6 +234,29 @@ describe('WorkspacesService', () => {
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(workspaces.pickDirectory()).resolves.toBeNull()
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
})
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
api.onListDirectory = () => Promise.resolve(ok(listing))
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
// The optional path is omitted from the payload, not sent as undefined.
expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
const listFailure = workspaces.listDirectory('/x')
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
})
it('opens a filesystem path through the host without local state', async () => {
@@ -263,3 +286,78 @@ describe('WorkspacesService', () => {
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
})
describe('startInitialSelection', () => {
function bench() {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
return { api, sessions, workspaces }
}
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
const b = bench()
const stop = b.workspaces.startInitialSelection()
// Nothing happens before both baselines land.
expect(b.api.callsOf('session.create')).toHaveLength(0)
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
await b.workspaces.refresh()
await b.sessions.refresh()
// Store notifications and the connect round trip are microtask-batched.
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
stop()
})
it('stays idle when a session is already current or no recent Workspace exists', async () => {
const withCurrent = bench()
withCurrent.api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
}))
await withCurrent.sessions.refresh()
withCurrent.sessions.open(sid('s1'))
withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
const stopCurrent = withCurrent.workspaces.startInitialSelection()
await withCurrent.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
stopCurrent()
const noRecent = bench()
const stopEmpty = noRecent.workspaces.startInitialSelection()
await noRecent.workspaces.refresh()
await noRecent.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
stopEmpty()
})
it('a failed connect returns to waiting and retries on the next list change', async () => {
const b = bench()
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
const stop = b.workspaces.startInitialSelection()
await b.workspaces.refresh()
await b.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(1)
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
// Recovery: the next workspace-list change re-runs the reconcile.
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
await b.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(2)
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
stop()
})
})

View File

@@ -10,7 +10,7 @@
* machinery — everything mounts the production implementations.
* @module @deepseek-ai/dsh-client-test-runtime
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
* this compilation unit sees only the runtime's 'root' row, but consumer
* programs merge their own keys in; the rule fires on the narrow-map view. */

View File

@@ -84,6 +84,14 @@ export class FixtureSession implements SessionFace {
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
updateQueue(): never {
throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
* @returns never — always throws.
@@ -92,6 +100,14 @@ export class FixtureSession implements SessionFace {
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
command(): never {
throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
* @returns never — always throws.
@@ -99,6 +115,14 @@ export class FixtureSession implements SessionFace {
loadOlder(): never {
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
rename(): never {
throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`)
}
}
/** One live test session: fixture-derived stores plus its minted scope state. */
@@ -183,6 +207,7 @@ export class TestSessions implements ISessions {
id,
displayTitle: fixture.id,
running: false,
waitingApproval: false,
blank: false,
updatedAt: this.records.size + 1,
...fixture.summary,
@@ -220,6 +245,20 @@ export class TestSessions implements ISessions {
await this.stabilize(() => { record.snapshot.update(mutate) })
}
/**
* Update a session's list row (the wire-echo stand-in: title settles,
* running flips — components subscribed via useSessions re-render).
* @param id - session id.
* @param patch - summary fields to merge over the row.
*/
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
const record = this.require(id)
record.summary = { ...record.summary, ...patch }
await this.stabilize(() => {
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
})
}
/**
* Switch the current selection (undefined = the no-session empty state).
* @param id - session id to select, or undefined to clear.

View File

@@ -1,7 +1,7 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceListState } from './fixtures.ts'
import type { Stabilizer } from './fixtures.ts'
@@ -109,6 +109,48 @@ export class TestWorkspaces implements IWorkspaces {
return null
}
/**
* Browse listing (recorded). The default serves an empty home level; stub
* to shape a tree.
* @param path - absolute directory to list; absent lists the home level.
* @returns the level's listing.
*/
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
// The signal is recorded and forwarded like the production face passes
// it to the wire, so cancellation integration tests can observe or
// reject on a superseded scan.
this.calls.push({ method: 'listDirectory', args: [path, signal] })
const stub = this.stubs.get('listDirectory')
if (stub !== undefined) return await (stub(path, signal) as Promise<DirectoryListing>)
// The chain runs root-to-target inclusive, per the DirectoryListing
// contract — a bare root crumb would mislabel the level in browsers
// driven by this double.
return {
path: '/home/test',
home: '/home/test',
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'test', path: '/home/test', hidden: false },
],
entries: [],
truncated: false,
}
}
/**
* Browse child creation (recorded). The default joins parent and name.
* @param path - absolute existing parent directory.
* @param name - single path segment.
* @returns the created directory's absolute path.
*/
async createDirectory(path: string, name: string): Promise<string> {
this.calls.push({ method: 'createDirectory', args: [path, name] })
const stub = this.stubs.get('createDirectory')
if (stub !== undefined) return await (stub(path, name) as Promise<string>)
return `${path}/${name}`
}
/**
* Rename a Workspace (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.

View File

@@ -322,6 +322,32 @@ describe('workspaces', () => {
expect(stub).toHaveBeenCalledOnce()
await runtime.dispose()
})
it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => {
const runtime = await runtimeWithFrame()
// Defaults: an empty home level and parent/name joining.
await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] })
await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' })
await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh')
// The recorded signal seat mirrors the production face (undefined here;
// cancellation tests pass and observe a real one).
expect(runtime.workspaces.calls).toEqual([
{ method: 'listDirectory', args: [undefined, undefined] },
{ method: 'listDirectory', args: ['/home/test', undefined] },
{ method: 'createDirectory', args: ['/home/test', 'fresh'] },
])
// Stubs replace the defaults like every sibling method.
const listing = { path: '/x', home: '/x', crumbs: [], entries: [] }
const listStub = vi.fn(() => Promise.resolve(listing as never))
runtime.workspaces.stub('listDirectory', listStub)
runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never)))
const scan = new AbortController()
await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing)
// The stub receives the signal too, like the production face gives the wire.
expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal)
await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made')
await runtime.dispose()
})
})
describe('feature mount and disposal', () => {
@@ -441,8 +467,11 @@ describe('fixture session face', () => {
await runtime.sessions.add({ id: 's1' })
const bare = runtime.sessions.behavior('s1')
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
expect(() => bare.command()).toThrow(/command is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
expect(() => bare.rename()).toThrow(/rename is not stubbed/)
await runtime.dispose()
})

View File

@@ -6,6 +6,7 @@
* lightningcss inside the bundle: importing `x.module.css` yields the
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
* tag at factory execution (the loader removes plugin-owned tags on unload).
* The virtual loader registers each real stylesheet as a watch dependency.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
@@ -127,6 +128,8 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
// The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
this.addWatchFile(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.

View File

@@ -4,7 +4,7 @@
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claimspace / 带参 enter与生命周期记账被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key每个会话恒为 agent-backed因此 `command.list({sessionId})` 是唯一的寻址形状source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。

View File

@@ -13,8 +13,10 @@
display: flex;
flex-direction: column;
min-width: 220px;
/* Height cap: the 320px design maximum, clamped at runtime to the space
* above the composer (inline max-height set in PopupSelectView.tsx). */
max-height: 320px;
overflow-y: auto;
overflow: hidden;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -26,6 +28,13 @@
outline: none;
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
@@ -34,11 +43,11 @@
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
}
.rowActive {
background: var(--dsw-alias-fill-hover);
background: var(--dsw-alias-interactive-bg-hover);
}
.label {
@@ -50,19 +59,20 @@
.detail {
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.check {
display: inline-flex;
color: var(--dsw-alias-text-secondary);
flex: none;
color: var(--dsw-alias-label-primary);
}
.status {
padding: 8px;
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
padding: 8px 10px;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
.search {
@@ -72,7 +82,7 @@
border-radius: 8px;
background: transparent;
font-size: 13px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
outline: none;
}
@@ -97,6 +107,6 @@
border-radius: 6px;
background: transparent;
font-size: 12px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}

View File

@@ -3,19 +3,23 @@
* store into the conversation.input.overlay anchor. Unlike the slash menu
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
* inner search input takes focus, plain typing filters the loaded options
* locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to
* the composer, and ←→ keep the search input's native caret. Any pointer
* interaction outside the box dismisses (the click's own target takes
* focus). Closed state renders null; the overlay slot stays mounted.
* locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape
* dismisses back to the composer, and ←→ keep the search input's native
* caret. Any pointer interaction outside the box dismisses (the click's own
* target takes focus). Closed state renders null; the overlay slot stays
* mounted. The card height clamps to the space above the composer.
*/
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
/** Design cap on the card height (same MenuDropdown family as the slash menu). */
const MAX_HEIGHT = 320
/** Injected business face of the popupSelect overlay entry. */
export interface PopupSelectInjected {
/** The session's shell controller (state store + verbs; the view never touches the open-context type). */
@@ -34,6 +38,17 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
)
const cardRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
// The card is bottom-anchored above the composer; clamp the design cap to
// the space above it, re-measured on every store update.
const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state)
const active = state.open ? state.active : null
// The search input keeps focus while arrows move a virtual highlight, so
// the browser never scrolls the active row into view — do it here.
useEffect(() => {
if (active === null) return
cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active])
// Focus ownership: the search input grabs on open (the design's
// transient-layer rule), and ANY outside pointer interaction dismisses —
@@ -42,7 +57,6 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
// takes focus naturally, so no focusComposer here.
useEffect(() => {
if (!state.open) return
searchRef.current?.focus()
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
@@ -51,6 +65,11 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
if (state.open) searchRef.current?.focus()
}, [state.open])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
@@ -83,6 +102,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={`/${String(state.command)} options`}
onKeyDown={onKeyDown}
>
@@ -108,7 +128,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`}>
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}

View File

@@ -43,6 +43,24 @@ export interface CommandContribution {
readonly ui: CommandUiSpec
}
/**
* A UI decoration hung on one HOST command: what its BARE invocation does on
* this client. Not a second command — the host command keeps its catalog
* row, its argument claim (space / argued enter), and its lifecycle logging;
* the decoration replaces only the bare menu-pick/enter with a popup whose
* onSelect typically submits a completed line back through command.execute.
* A decoration never manufactures a row: a name with no host catalog entry
* in the session's directory simply never reaches the decoration.
*/
export interface CommandDecoration {
/** The HOST command name this decorates (without the leading slash). */
readonly name: string
/** Capability filter, called with a fresh projection per bare invocation. */
available(session: ClientSessionContext): boolean
/** The bare-invocation UI (this phase: popupSelect only). */
readonly ui: CommandUiSpec
}
/** The `ctx.command` service face visible to business packages. */
export interface CommandServiceContract {
/**
@@ -50,6 +68,11 @@ export interface CommandServiceContract {
* names throw at registration.
*/
register(contribution: CommandContribution): () => void
/**
* Hang a bare-invocation decoration on one host command; effect disposer.
* Duplicate names throw at registration.
*/
decorate(decoration: CommandDecoration): () => void
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
popupFor(actx: ClientContext): unknown
}

View File

@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
} from './contract.ts'
declare module 'cordis' {

View File

@@ -14,7 +14,7 @@ import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SubmitOutcome,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandServiceContract } from './contract.ts'
import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts'
import type { CommandDescriptor } from './directory.ts'
import { CommandDirectory } from './directory.ts'
import { PopupSelectController } from './popup.ts'
@@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts'
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
readonly contributions: Map<string, CommandContribution>
readonly decorations: Map<string, CommandDecoration>
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
}
@@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
/**
* @param ctx - owning root context (plugin fiber; the service registers
@@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract {
return () => { void dispose() }
}
/**
* Hang a bare-invocation decoration on one host command; effect disposer
* (rides the caller's fiber). Duplicate names throw.
* @param decoration - host command name + availability + popup spec.
* @returns the disposer removing the registration.
*/
decorate(decoration: CommandDecoration): () => void {
const dispose = this.ctx.effect(() => {
const { decorations } = this.live
if (decorations.has(decoration.name)) {
throw new Error(`ui-command: duplicate decoration for /${decoration.name}`)
}
decorations.set(decoration.name, decoration)
return () => { decorations.delete(decoration.name) }
}, 'command.decorate()')
return () => { void dispose() }
}
/**
* Resolve the per-session popup controller (lazy; dies with the session
* scope). The controller's consume callback dispatches the scoped
@@ -148,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract {
.filter(c => req.position === 'leading' || c.hint === undefined)
}
/** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
private dispatch(pick: SlashPick): PickOutcome {
const name = pick.candidate.name
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(pick.session)) {
this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
return 'handled'
}
const desc = this.directory.resolve(pick.session.sessionId, name)
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
// A decoration replaces the HOST row's bare invocation with its popup;
// it decorates only a resolvable host command (checked above), never
// manufactures one, and never touches the argument claim below.
const decoration = this.live.decorations.get(name)
if (decoration !== undefined && decoration.available(pick.session)) {
this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
return 'handled'
}
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
// Menu-pick execute consumes the trigger span before the detached run
// (scoped event; the input owns the CAS guard).
@@ -193,12 +220,21 @@ export class CommandService extends Service implements CommandServiceContract {
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(session)) {
if (!bare) return undefined
this.openPopup(contribution, session, { via: 'enter', token })
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
return 'handled'
}
await this.directory.ensureReady(session.sessionId, signal)
const desc = this.directory.resolve(session.sessionId, name)
if (desc === undefined) return undefined
// Bare enter on a decorated host command opens its popup; an argued line
// never consults the decoration (the claim/detached paths below own it).
if (bare) {
const decoration = this.live.decorations.get(name)
if (decoration !== undefined && decoration.available(session)) {
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
return 'handled'
}
}
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
if (!bare) return undefined
this.consumeVia(session.sessionId, { via: 'enter', token })
@@ -206,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract {
return 'handled'
}
/** Open the session's popup for one contribution (menu pick / bare enter). */
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
private openPopup(
contribution: CommandContribution,
name: string,
ui: CommandContribution['ui'],
session: ClientSessionContext,
segment: TokenSegment,
): void {
const actx = this.scopeFor(session.sessionId)
if (actx === undefined) return
this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
this.popupFor(actx).open(name, ui, session, segment)
}
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */

Some files were not shown because too many files have changed in this diff Show More