Merge branch 'master' into feat/web-preview-badge

This commit is contained in:
Ziya
2026-08-05 23:29:04 -07:00
committed by GitHub
2511 changed files with 48883 additions and 24908 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 apps/cli/README.md
README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01
README.md: ce7af5a299e45d6f107686aff043246914dce8ed
README.zh.md: e97fec9d6bb726cb1e419a1ca2fa1871d4d203ca

View File

@@ -2,75 +2,24 @@
English | [中文](README.zh.md)
The `dsh` command has three entry modes: a required raw config overlay, a one-shot headless prompt, and the Web UI. [`src/args.ts`](src/args.ts) owns the Commander grammar, and [`src/bin.ts`](src/bin.ts) dynamically imports only the selected runner. Unknown commands and leaked options fail with a nonzero exit code.
The `dsh` command is the product launcher for raw Cordis configurations, the Web UI, and one-shot headless tasks. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero.
## Entry modes
| Command | Purpose |
|---|---|
| `dsh --config ./app.cordis.yml` | Run an explicit patch-list configuration over the shipped base. |
| `dsh web` | Start the browser UI with the shipped Web composition and optional personal configuration. |
| `dsh -p "task"` | Run one fresh persisted session, print the final answer, and exit. |
The invoking directory is the default workspace root. Web and headless share the shipped provider, persistence, policy, tool, repository Plugin, and telemetry composition; raw config selects its own deployment-specific front door.
## Raw config
Raw `dsh` requires an explicit patch-list config:
Raw `dsh` requires `--config`. The named patch list is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml); it is not a complete replacement tree and does not add a surface overlay or personal `$DSH_HOME/config.yaml`. Use `--dump-default-config` and `--dump-config` to inspect the resulting tree without booting it.
```sh
dsh --config ./app.cordis.yml
```
The [CLI behavior reference](reference/README.md) owns exact overlay precedence, flags, shutdown behavior, deployment defaults, and the source launcher.
The named file is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
## Development
A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
Inspect the effective tree without booting it:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
## Web and headless
`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config <path>` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
## Shared deployment behavior
The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it.
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
## Source launcher
Link the source-running launcher onto PATH:
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`.
Production Web and headless runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract.

View File

@@ -2,75 +2,24 @@
[English](README.md) | 中文
`dsh` 命令有三种入口模式:必需的原始配置 overlay、一次性 headless 提示词,以及 Web UI。[`src/args.ts`](src/args.ts) 拥有 Commander 命令语法,[`src/bin.ts`](src/bin.ts) 只会动态导入选中模式的运行器。未知命令和误传入其他模式的选项都会以非零代码退出。
`dsh` 命令是原始 Cordis 配置、Web UI 和一次性无头任务的产品启动器。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。
## 入口模式
| 命令 | 用途 |
|---|---|
| `dsh --config ./app.cordis.yml` | 在随附基础配置之上运行显式 patch 列表配置。 |
| `dsh web` | 使用随附 Web 组合和可选个人配置启动浏览器 UI。 |
| `dsh -p "task"` | 运行一个新的持久化会话,打印最终答案并退出。 |
调用目录是默认 workspace 根目录。Web 与无头模式共享随附的提供方、持久化、策略、工具、repository Plugin 和遥测组合;原始配置自行选择部署专用前端入口。
## 原始配置
原始 `dsh` 要求显式传入一份 patch 列表配置:
原始 `dsh` 必须提供 `--config`。指定的 patch 列表直接应用到 [`config/base.cordis.yml`](config/base.cordis.yml) 之上;它不是完整替代树,也不会添加 surface overlay 或个人 `$DSH_HOME/config.yaml`。使用 `--dump-default-config``--dump-config` 可在不启动的情况下检查生成的配置树。
```sh
dsh --config ./app.cordis.yml
```
[CLI命令行界面行为参考](reference/README.md)负责确切的 overlay 优先级、flag、关闭行为、部署默认值和源码启动器。
指定文件会通过 Include 插件的 patch 算法,直接应用在 [`config/base.cordis.yml`](config/base.cordis.yml) 之上。它不是完整替换树,系统也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。base 有意不包含启动 agent智能体或交互入口必需的 overlay 负责选择这些部署细节。相对配置路径以调用目录为基准解析。配置解析、schema 校验、模块解析或插件启动失败都会被报告并以非零代码退出。SIGINT 和 SIGTERM 会在退出前 dispose资源释放已挂载的根上下文。
## 开发
patch 通过 `id` 定位 base 配置项,并替换该配置项的完整 `config` 值,而不是深度合并各个键。它也可以插入新配置项:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
可以在不启动应用的情况下检查有效配置树:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` 只打印随附 base。`--dump-config` 要求提供 `--config`,并打印带来源注释的 base 与 overlay。组合过程使用 `@cordisjs/plugin-include``applyEntryPatches``entryListSchema``!!js` 表达式保持未求值状态,未匹配的 patch 目标会报告到 stderr。
## Web 与 headless
`dsh web` 会启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续应用该文件。`dsh web --config <path>` 会以显式 patch 列表替换个人层。`--host``--port``--workspace-root` 和可重复的 `--trusted-host` 值会转为 Web 宿主 patch各自所属插件的 schema 会在启动时校验它们。`--dev` 会挂载客户端插件 HMR热模块替换接收器要实现无需刷新的客户端 bundle 更新,还需单独运行 `pnpm run dev:web` watcher。
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
生产 Web 运行器需要已构建的包package与前端产物`pnpm run build`)。它默认通过 `http://127.0.0.1:3080` 提供服务。绑定所有网络接口时,系统也会信任本机探测到的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任边界所接受的具名权威。
`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md``CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。
`DSH_TOOLS_MODE` 为 Web/headless 进程选择 `native``code``both`;其他值会在启动时失败。[`config/core-web.cordis.yml`](config/core-web.cordis.yml) 是可选的 Web overlay它在保留随附宿主、浏览器、workspace、持久化与权限组合的同时将面向原生模型的工具缩减为持久 `bash``str_replace_editor`
## 共享部署行为
base 会挂载原生 DeepSeek 适配器、设置与凭据提供方、稳定的 `web_search`、仓库插件支持与会话遥测。提供方凭据位于 `$DSH_HOME/.env` 或环境中,且仍可轮换,因为启动器绝不会把凭据文件提升进 `process.env`。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;除非 overlay 插入提供方并启用 `web_fetch`,否则后者处于禁用状态。
会话事件默认以 OTLP/HTTP 日志的形式流式发送。`DSH_TELEMETRY_OTLP_URL` 用于选择其他 collector。`DSH_TELEMETRY_DISABLED` 的任何非空值都会在启动前禁用遥测配置项。随附 base 没有遥测脱敏规则,因此导出记录可能包含消息文本、工具参数与结果,以及 workspace 路径;该部署决策由[遥测 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) 统一定义。
空的 `repository-plugins` 配置项允许 Web/headless 个人配置与原始 overlay 挂载已准备的不可变仓库插件 generation。详见[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI命令行界面还将 `@deepseek-ai/dsh-mcp-client` 作为 overlay 依赖发布,但默认不启用任何 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
## 源码启动器
将以源码运行的启动器链接到 PATH
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
它会通过自身实际路径解析该检出,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts``TSX_TSCONFIG_PATH` 固定指向检出根目录,因此 workspace 包解析不受调用目录影响。`pnpm run dsh` 使用同一入口并转发参数。构建后的形式是执行 `pnpm run build` 后的 `apps/cli/lib/bin.js`
生产环境的 Web 和无头运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。

View File

@@ -54,6 +54,8 @@ flowchart LR
cfg --> plugin_dsh_base_approval
plugin_dsh_base_permission["permission<br/>@deepseek-ai/dsh-permission"]
cfg --> plugin_dsh_base_permission
plugin_dsh_base_bash_env["bash-env<br/>@deepseek-ai/dsh-bash-env"]
cfg --> plugin_dsh_base_bash_env
plugin_dsh_base_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
cfg --> plugin_dsh_base_tool_bash
plugin_dsh_base_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"]
@@ -171,6 +173,7 @@ flowchart LR
| `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `bash-env` | `@deepseek-ai/dsh-bash-env` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |

View File

@@ -173,6 +173,9 @@
sandbox: danger-full-access
approval: never
- id: bash-env
name: '@deepseek-ai/dsh-bash-env'
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'

View File

@@ -8,9 +8,8 @@
"dsh": "lib/bin.js"
},
"files": [
"lib/bin.js",
"config",
"src"
"lib/*.js",
"config"
],
"license": "BSD-3-Clause",
"dependencies": {
@@ -22,6 +21,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-env": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
@@ -75,6 +75,7 @@
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
@@ -115,6 +116,7 @@
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
@@ -131,7 +133,8 @@
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"commander": "^15.0.0",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"
"js-yaml": "^4.2.0",
"node-addon-require-builtin": "^0.1.4"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",

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 apps/cli/reference/README.md
README.md: b37ec9ed61ea4e9899a51316065d4188f30997ad
README.zh.md: ca29808a6c8e670f0d0b82c59b1a2c1fa0e13565

View File

@@ -0,0 +1,76 @@
# `dsh` CLI behavior reference
English | [中文](README.zh.md)
This reference defines the raw-config, Web, and headless command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
## Raw config
Raw `dsh` requires an explicit patch-list config:
```sh
dsh --config ./app.cordis.yml
```
The named file is applied directly over [`config/base.cordis.yml`](../config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
Inspect the effective tree without booting it:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
## Web and headless
`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](../config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config <path>` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
## Shared deployment behavior
The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it.
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
## Source launcher
Link the source-running launcher onto PATH:
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`.

View File

@@ -0,0 +1,76 @@
# `dsh` CLI命令行界面行为参考
[English](README.md) | 中文
本参考定义原始配置、Web 和无头命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
## 原始配置
原始 `dsh` 必须提供显式 patch 列表配置:
```sh
dsh --config ./app.cordis.yml
```
指定文件通过 Include 插件的 patch 算法直接应用到 [`config/base.cordis.yml`](../config/base.cordis.yml) 之上。它不是完整替代树,也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。基础配置刻意不包含启动 agent智能体或交互前端入口必填 overlay 负责选择这些部署细节。相对配置路径从调用目录解析。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose资源释放再退出。
patch 通过 `id` 定位基础配置行,并替换该行完整的 `config`而不是深度合并各键。patch 列表也可插入新行,只要随附 Loader 能解析其插件模块:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
可在不启动的情况下检查生效的配置树:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` 只打印随附基础配置。`--dump-config` 必须与 `--config` 同时使用,并打印基础配置和带来源注释的 overlay。组合使用 `@cordisjs/plugin-include``applyEntryPatches``entryListSchema``!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
## Web 与无头模式
`dsh web` 启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](../config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续加载它。`dsh web --config <path>` 用显式 patch 列表替代该个人层。`--host``--port``--workspace-root` 和可重复的 `--trusted-host` 值会成为 Web 宿主 patch负责这些值的插件 schema 会在启动时验证它们。`--dev` 挂载客户端插件 HMR热模块替换接收器若要无刷新更新客户端 bundle还需单独运行 `pnpm run dev:web` watcher。
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
`dsh -p "task"` 使用同一基础配置和 Web 组合,并加载启动时的个人配置;它在 OS 分配的端口上启动 Web 宿主,运行一个新的持久化会话,打印最终答案并退出。它不接受 `--config` 或原始配置 dump flag。
Web 和无头进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果无头模式正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
两种模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md``CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。Web 监视有效的个人配置编辑;无头模式只在启动时读取该文件。[app-boot 个人配置契约](../../../packages/ui/app-boot/README.md#personal-config)负责配置层优先级、凭据存储、实时更新失败行为和 `$DSH_HOME` 解析。
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
`DSH_TOOLS_MODE` 为 Web无头进程选择 `native``code``both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选 Web overlay它在保留随附宿主、浏览器、workspace、持久化和权限组合的同时把原生模型 surface 缩减为持久 `bash``str_replace_editor`
## 共享部署行为
基础配置挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 overlay 插入提供方并启用 `web_fetch` 后,该工具才可用。
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
`repository-plugins` 行让 Web无头个人配置和原始 overlay 能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 契约](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为 overlay 的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
## 源码启动器
把源码运行启动器链接到 PATH
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
它通过 real path 解析 checkout并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts``TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`

View File

@@ -32,39 +32,50 @@ async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>
}
/**
* Consume mux frames until the task turn ends, per the cli-demo runOneShot
* correlation precedent: anchor on the first turn/start whose trigger kind is
* 'message' (startup-injected turns are skipped), aggregate text from that
* turn's assistant/message events (last one wins), finish on its turn/end.
* Consume mux frames until the agent reaches idle, per the one-shot CLI
* idle-to-idle contract: the stream opens immediately before the prompt, and
* its first observed turn/start begins the task. Text is the last committed
* assistant message of the whole interval (steering or injected work may run
* further turns before quiescence), and the outcome reason is the final
* turn/end's kind. Idleness is signalled out of band by the caller's
* `agent/status` subscription; the stream itself carries no status frame.
* @param frames - the mux stream opened before the prompt.
* @param sessionId - the headless session.
* @param idle - resolves when the agent reaches quiescence.
* @returns the aggregated outcome.
*/
async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
let targetTurn: number | undefined
async function consumeUntilIdle(
frames: AsyncIterable<RpcRequest<MuxFrame>>,
sessionId: SessionId,
idle: Promise<void>,
): Promise<TurnOutcome> {
let started = false
let text = ''
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') {
process.stderr.write(`dsh: stream error: ${payload.error.message}\n`)
return { text, reason: 'error' }
}
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
if (targetTurn === undefined) {
if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn
continue
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
return { text, reason: event.data.reason.kind }
let reason: string = 'error'
void (async () => {
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') return
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
if (event.type === 'turn/start') {
started = true
continue
}
if (!started) continue
if (event.type === 'assistant/message') {
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end') reason = event.data.reason.kind
}
} catch (error: unknown) {
process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
} catch (error: unknown) {
process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
return { text, reason: 'error' }
})()
await idle
return { text, reason }
}
/**
@@ -99,7 +110,12 @@ export async function runHeadless(task: string): Promise<void> {
// to a remote HTTP carrier unchanged.
const abort = new AbortController()
const frames = api.events.mux({}, abort.signal)
const done = consumeUntilTurnEnd(frames, created.sessionId)
const idle = new Promise<void>((resolve) => {
ctx.on('agent/status', (agent, status) => {
if (agent.id === created.sessionId && status === 'idle') resolve()
})
})
const done = consumeUntilIdle(frames, created.sessionId, idle)
await unwrap(await api.sessions.prompt({
sessionId: created.sessionId,

View File

@@ -11,7 +11,7 @@ import type { Context } from 'cordis'
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import type {} from '@deepseek-ai/dsh-bash-env'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'

View File

@@ -23,6 +23,9 @@
{
"path": "../../packages/ui/app-boot"
},
{
"path": "../../packages/bash/bash-env"
},
{
"path": "../../packages/bash/tool-bash"
},

View File

@@ -8,6 +8,9 @@
"./dist/*": "./dist/*",
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"dev": "vite",
@@ -24,6 +27,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",

View File

@@ -2,7 +2,6 @@
// the same locale-aware, in-page risk confirmation. Zero model calls: the
// scenario boots the shipped Web composition and exercises the real
// permission projection, client command path, HTTP RPC, and pushed update.
import { mkdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -12,27 +11,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
/**
* connectFreshWorkspace twin over the product default Chinese locale (the
* shared helper's anchors assume the English page every other scenario
* boots; this scenario deliberately keeps zh, so the localized picker
* copy is the anchor set).
*/
async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: '打开', exact: true }).click()
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
.waitFor({ timeout: 15_000 })
}
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')

View File

@@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
const row = page.locator('[data-sample="bash"]').first()
const call = row.locator('xpath=..')
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1)
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
@@ -64,7 +64,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
await call.getByText('OUT', { exact: true }).waitFor()
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2)
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
// The borrowed fixture's UTC date is still the previous day in PDT;

View File

@@ -7,7 +7,7 @@
// content from the keyless FixtureApiClient transport.
//
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident approval
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
import { readFileSync } from 'node:fs'
@@ -105,14 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The resident approval fixture proves the assembled workspace plugin
// distinguishes a blocked running session from an ordinarily busy one.
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that
// actionable wait instead of the underlying running state.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
within(waitingRow).getByText('Waiting for approval')
within(waitingRow).getByText('Waiting for answer')
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(waitingTitle)
@@ -120,6 +121,21 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
// Resolve the resident approval so the ordinary composer bar (which owns
// ContextMeter) resumes without replacing the session shell. This minimal
// boot graph intentionally does not mount the separate question UI plugin.
fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
// The fixture mirrors all three token-meter projections, so the assembled
// ContextMeter reaches its composition panel instead of only the occupancy
// fallback path.
const contextTrigger = await screen.findByRole('button', { name: /of context used/ })
fireEvent.click(contextTrigger)
const contextPanel = await screen.findByRole('dialog', { name: 'of context used' })
within(contextPanel).getByText('System prompt')
within(contextPanel).getByText('Tools')
within(contextPanel).getByText('Messages')
// The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/

View File

@@ -260,7 +260,9 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await composer.inputValue()).toBe('')
expect(await composer.isEnabled()).toBe(true)
expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
expect(child.session.events.filter(event => carries(event, CONTINUE_PROMPT))).toHaveLength(1)
expect(child.session.events.filter(event => (
event.type === 'user/message' && carries(event, CONTINUE_PROMPT)
))).toHaveLength(1)
const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))

View File

@@ -608,6 +608,9 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.setViewportSize({ width: 700, height: 900 })
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
// this scenario switches sessions while pinning the narrow Chat scroll owner.
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor)

View File

@@ -1,7 +1,7 @@
// Synthetic long-chat history for browser behavior contracts. The fixture is
// generated through Session so pagination exercises the same event shapes as
// persisted conversations, while unique markers let tests identify semantic
// rows without depending on CSS-module names or the eventual virtualizer DOM.
// persisted conversations, while unique markers identify semantic rows
// without depending on CSS-module names or virtualizer DOM positions.
import {
CallId,
createAssistantMessage,
@@ -179,12 +179,11 @@ function fixtureLog(session: Session): string {
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
const turns = options.turns ?? DEFAULT_TURNS
const markers = markerHelpers(options.markerPrefix)
const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const user = session.append('user/message', createUserMessage({
content: text(

View File

@@ -319,10 +319,9 @@ function fixtureLog(session: Session): string {
}
function smallSidebarFixture(): string {
const session = new Session(SessionId('perf-small-template'))
const session = Session.create(SessionId('perf-small-template'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const user = session.append('user/message', createUserMessage({
content: text('Inspect this compact synthetic session.'),
@@ -342,11 +341,10 @@ function smallSidebarFixture(): string {
}
function longHistoryFixture(): string {
const session = new Session(SessionId(LONG_SESSION_ID))
const session = Session.create(SessionId(LONG_SESSION_ID))
for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) {
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const user = session.append('user/message', createUserMessage({
content: text(

View File

@@ -0,0 +1,420 @@
// Web e2e scenario: the input card holds one horizontal position across the
// Chat and Trajectory tabs.
//
// The composer seat is the same node in both tabs, but it measures itself
// against a different edge in each (see
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
// scroller's content box — the box a space-consuming scrollbar shortens. A view
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
// Trajectory declares and which moves the column's own scrolling into the view)
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
// So the two tabs disagreed by exactly the bar's width for as long as the
// transcript overflowed: the card jumped sideways on every tab switch, and
// inside Chat alone at the moment a growing transcript started to scroll. The
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
// and states the overlay branch as a scroll container on the same axes, so both
// edges are the same edge.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
// can assert the declarations exist but not that the two states land in the same
// place. What is asserted here is the user-visible fact — the card does not move
// — measured as the distance between the two tabs' card rectangles.
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree before
// this change as much as after it and every comparison below holds vacuously —
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
// and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
// same two tabs through it, which is what keeps the equal rectangles above from
// being explained by a tab switch that never reached the layout. It is the
// reported symptom as a number: the card moves 4px, half the 8px band, on each
// edge.
//
// Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
/**
* Committed golden of where the input card sits in each tab, at a wide viewport
* (card at its width cap) and a narrow one (card shrinking with the column).
*
* Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture
* that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the reservation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the difference the fix removes rather than only its absence.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
const FIXTURE = createChatScrollFixture({
markerPrefix: 'TAB_GEOMETRY',
title: 'COMPOSER_TAB_GEOMETRY long session',
turns: 24,
})
const SEED_ID = 'composer-tab-geometry-web-e2e'
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
const NARROW_VIEWPORT = { width: 800, height: 1000 }
/**
* Resize to one measurement viewport after the responsive sidebar and center
* column finish their track transition.
* @param page - the page under test.
* @param viewport - the viewport dimensions to apply.
* @param sidebarCollapsed - the sidebar state expected at this width.
*/
async function setMeasuredViewport(
page: Page,
viewport: { width: number; height: number },
sidebarCollapsed: boolean,
): Promise<void> {
await page.setViewportSize(viewport)
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
state: sidebarCollapsed ? 'attached' : 'detached',
timeout: 10_000,
})
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
const deadline = performance.now() + 5_000
let previous = host.getBoundingClientRect().width
let stableFrames = 0
while (performance.now() < deadline) {
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const current = host.getBoundingClientRect().width
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
if (stableFrames >= 3) return
previous = current
}
throw new Error('conversation width did not settle after the viewport changed')
})
}
/**
* The pre-fix cascade, injected into the page: the reservation dropped and the
* overlay branch back to a hidden box. `!important` beats the module rules
* without a rebuild, and the id lets the control be lifted again in the same
* session.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
`
/** The column scroller and the input card as the browser lays them out, in one tab. */
interface TabMetrics {
/** Resolved `scrollbar-gutter` on the column's scroller. */
gutter: string
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
overflowX: string
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
overflowY: string
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** True when the column's scroller actually scrolls — only Chat does. */
scrolls: boolean
/** Left edge of the input card in viewport coordinates. */
cardLeft: number
/** Right edge of the input card. */
cardRight: number
/** Width of the input card, capped at the composer card max width. */
cardWidth: number
}
/** One tab's metrics beside the other's, plus the distances between them. */
interface TabComparison {
chat: TabMetrics
trajectory: TabMetrics
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
leftShift: number
/** Distance between the two tabs' card right edges. */
rightShift: number
/** Difference between the two tabs' card widths. */
widthShift: number
}
/**
* Measure the column scroller and the input card in the tab currently shown.
* @param page - the page under test.
* @returns the scroller's resolved overflow style and the card's rectangle.
*/
function measureTab(page: Page): Promise<TabMetrics> {
return page.evaluate(() => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
if (host === null) throw new Error('conversation column scroller not in the DOM')
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
if (card === null) throw new Error('no input card inside the composer seat')
const style = getComputedStyle(host)
const hostRect = host.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
return {
gutter: style.scrollbarGutter,
overflowX: style.overflowX,
overflowY: style.overflowY,
band: hostRect.width - host.clientWidth,
scrolls: host.scrollHeight > host.clientHeight,
cardLeft: cardRect.left,
cardRight: cardRect.right,
cardWidth: cardRect.width,
}
})
}
/**
* Show one tab and wait for the view that owns it to be laid out.
* @param page - the page under test.
* @param tab - the tab to show.
*/
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
await page.getByRole('tab', { name: tab, exact: true }).click()
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
// Both measurements are taken after a paint, so a rectangle read mid-transition
// cannot be reported as a shift the cascade did not cause.
await page.evaluate(() => new Promise<void>((settle) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
}))
}
/**
* Measure both tabs and the distances between them, leaving Chat shown.
* @param page - the page under test.
* @returns each tab's metrics and the card's displacement between them.
*/
async function compareTabs(page: Page): Promise<TabComparison> {
await showTab(page, 'Chat')
const chat = await measureTab(page)
await showTab(page, 'Trajectory')
const trajectory = await measureTab(page)
await showTab(page, 'Chat')
return {
chat,
trajectory,
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
}
}
/**
* Run the pre-fix cascade in the page for one measurement, then lift it.
* @param page - the page under test.
* @returns the comparison as the column laid out before this change.
*/
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
const style = document.createElement('style')
style.id = id
style.textContent = css
document.head.append(style)
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
try {
return await compareTabs(page)
} finally {
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
}
}
/**
* Open the seeded session from the sidebar search.
*
* Cold summaries carry the temp workspace's basename, so the persisted first
* message is the stable identity to search for, and the query itself drives the
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
* is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
for (;;) {
if (await results.count() === 1) break
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
await page.waitForTimeout(200)
}
await results.click()
}
/**
* Render the golden body.
* @param wide - comparison at the viewport where the card sits at its width cap.
* @param narrow - comparison at the viewport where the card shrinks with the column.
* @param control - comparison at the wide viewport with the reservation removed.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
const section = (name: string, comparison: TabComparison): string[] => [
`## ${name}`,
'',
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
`- Chat reserved band: ${String(comparison.chat.band)}px`,
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
'',
]
return [
'# Input card position across the Chat and Trajectory tabs',
'',
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
...section('Wide viewport, reservation removed in the page (control)', control),
].join('\n').trimEnd()
}
describe('web e2e: input card position across view tabs', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, FIXTURE.log, SEED_ID)
// Scrollbars must take layout space here or the scenario proves nothing;
// see the file header for the measurement behind dropping this argument.
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession(page)
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
.waitFor({ timeout: 30_000 })
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// Vacuity guard, in two parts. A transcript that does not overflow gives
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
// would make the tabs agree without the reservation doing anything.
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0)
// The reservation reaches both states, which is the whole change: the same
// band, on a box that scrolls and on one that only holds a view.
expect(comparison.chat.gutter).toBe('stable')
expect(comparison.trajectory.gutter).toBe('stable')
expect(comparison.trajectory.band).toBe(comparison.chat.band)
// Declared as a scroll container on both axes rather than left to compute:
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
// horizontal axis computes to `auto` beside a scrolling one.
expect(comparison.trajectory.overflowY).toBe('auto')
expect(comparison.trajectory.overflowX).toBe('hidden')
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
expect(comparison.trajectory.scrolls).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place when the tab changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its
// width cap, so the pre-fix shift showed up as a centring difference — half
// the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const capped = await measureTab(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const comparison = await compareTabs(page)
// The other geometry, and a different failure: below the cap the card takes
// the column's width, so an unreserved gutter changed its WIDTH by the whole
// band instead of shifting it by half. Asserted against the capped
// measurement rather than against the cap's pixel value, which belongs to
// the stylesheet.
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('moves the card again once the reservation is removed in the page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
// its bar and the Trajectory branch goes back to a hidden box with none, and
// the card moves by half the band on each edge.
const comparison = await compareTabsWithoutReservation(page)
expect(comparison.chat.gutter).toBe('auto')
expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
// Restoring the sheet restores the fix, so the control cannot leak into the
// remaining measurements.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed tab geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const wide = await compareTabs(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const narrow = await compareTabs(page)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const control = await compareTabsWithoutReservation(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The seeded session is generated in-process, so the geometry golden is the
// whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})

View File

@@ -29,9 +29,7 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
(event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
)
const reason = turnEnd?.data.reason
const reasonSummary = reason?.kind === 'error'
? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status }
: { kind: reason?.kind }
const reasonSummary = { kind: reason?.kind }
expect(reasonSummary).toEqual({ kind: 'completed' })
const calls = events.filter(

View File

@@ -104,6 +104,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await input.press('Enter')
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
// The golden encodes an empty composer, and the button arriving does not
// mean the submitted text is gone yet: under load the capture caught a
// textbox still holding `/plan`.
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
const planStyle = await planButton.evaluate((element) => {

View File

@@ -192,9 +192,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const { settled } = await sendPrompt()
await settled
await page.getByRole('tab', { name: 'Trajectory' }).click()
// The boundary marker row itself is a 0-height hairline except at the
// table tail; the marker button is absolutely positioned and stays
// visible, so wait on it directly.
const tailRequest = page.locator('tr[data-request-only="true"]').last()
await tailRequest.waitFor({ timeout: 10_000 })
const requestMarker = tailRequest.getByRole('button', { name: /Request #/ })
await requestMarker.waitFor({ timeout: 10_000 })
const markerWithinTable = await requestMarker.evaluate((element) => {
const marker = element.getBoundingClientRect()

View File

@@ -0,0 +1,128 @@
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-cjk-strong', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-cjk-strong/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-cjk-strong-web-e2e'
const DONE = 'CJK_STRONG_DONE'
const CASES = [
['**注意:**内容', '注意:', '注意:内容'],
['**Notice:**内容', 'Notice:', 'Notice:内容'],
['**事件中间件waterfall**实现', '事件中间件waterfall', '事件中间件waterfall实现'],
['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'],
['**句号。**后续', '句号。', '句号。后续'],
['**Period.**后续', 'Period.', 'Period.后续'],
['**提醒!**继续', '提醒!', '提醒!继续'],
['**Warning!**继续', 'Warning!', 'Warning!继续'],
] as const
/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */
function markdownFixture(): string {
const session = Session.create(SessionId('markdown-cjk-strong-source'))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Render adjacent CJK strong emphasis.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'CJK strong emphasis',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## CJK strong emphasis',
'',
...CASES.flatMap(([markdown]) => [markdown, '']),
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
describe('web e2e: CJK-adjacent Markdown strong emphasis', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, markdownFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('renders punctuation-terminated strong spans before adjacent CJK text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-cjk-strong'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
const strong = page.locator('[class*="markdown"] strong')
await expect.poll(() => strong.count(), { timeout: 10_000 }).toBe(CASES.length)
expect(await strong.allTextContents()).toEqual(CASES.map(([, expected]) => expected))
for (const [, , paragraph] of CASES) {
expect(await page.getByText(paragraph, { exact: true }).count()).toBe(1)
}
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -82,11 +82,8 @@ async function stopServer(server: Server): Promise<void> {
/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
function markdownImageFixture(remoteUrl: string): string {
const session = new Session(SessionId('markdown-image-source'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const session = Session.create(SessionId('markdown-image-source'))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Show the Markdown image policy.' }],
source: { kind: 'user' },

View File

@@ -0,0 +1,138 @@
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-inline-code-links', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-inline-code-links/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-inline-code-links-web-e2e'
const DONE = 'INLINE_CODE_LINK_DONE'
/** Build a settled assistant reply with linkable URL code and inert code controls. */
function markdownFixture(linkUrl: string): string {
const session = Session.create(SessionId('markdown-inline-code-links-source'))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Show the local preview URL.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Inline code links',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## Inline code links',
'',
`Preview: \`${linkUrl}\``,
'',
`Standard: [Open preview](${linkUrl})`,
'',
`Command: \`curl ${linkUrl}\``,
'',
'Unsafe: `javascript:alert(1)`',
'',
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
describe('web e2e: Markdown inline-code links', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let linkUrl: string
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString()
await seedSession(scaffold, markdownFixture(linkUrl), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('opens a complete HTTP URL from inline code and leaves other code inert', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-inline-code-links'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
const inlineCodeLink = page.locator('[class*="markdown"] code a')
await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1)
expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl)
expect(await inlineCodeLink.getAttribute('target')).toBe('_blank')
expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer')
await inlineCodeLink.focus()
expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true)
const popupPromise = page.waitForEvent('popup')
await inlineCodeLink.click()
const popup = await popupPromise
await popup.waitForURL(linkUrl, { timeout: 15_000 })
expect(popup.url()).toBe(linkUrl)
await popup.close()
expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0)
expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
.split(linkUrl).join('{{linkUrl}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -0,0 +1,130 @@
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'math-rendering-web-e2e'
const DONE = 'MATH_RENDERING_DONE'
/** Build a settled assistant reply that exercises every supported math delimiter. */
function mathFixture(): string {
const session = Session.create(SessionId('math-rendering-source'))
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
session.append('turn/start', {
turn: 1,
})
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Render this mathematical proof.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Math rendering',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## Math rendering',
'',
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
'',
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify({
...event,
time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
describe('web e2e: settled Markdown math rendering', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, mathFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -126,9 +126,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', {
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Select model', exact: true })
.waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
await page.getByRole('button', { name: 'Copy' }).first().focus()

View File

@@ -58,7 +58,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
// The dormant pi-ai adapter contributes its whole installed catalog; no
// provider is configured yet, so the page is one add button.
const add = dialog.getByRole('button', { name: '+ 添加提供方' })
const add = dialog.getByRole('button', { name: '添加提供方' })
await add.waitFor({ timeout: 10_000 })
// The button enables once the dormant catalog lands in the join.
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)

View File

@@ -12,7 +12,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
@@ -22,6 +22,7 @@ import {
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
@@ -160,7 +161,60 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
// Opened here rather than inherited: the credential test reloads the page
// to exercise the welcome step, so nothing carries an open dialog across.
await page.getByRole('button', { name: '设置', exact: true }).click()
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '模型' }).click()
const deepSeek = settings.getByText('DeepSeek', { exact: true }).first()
await deepSeek.waitFor({ timeout: 10_000 })
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
await settings.getByText('自定义设置').click()
await settings.getByRole('button', { name: /删除模型/ }).first().click()
await settings.getByRole('button', { name: '添加模型' }).click()
const customModelId = settings.getByLabel('模型 ID 2')
await customModelId.fill('private-preview')
await settings.getByLabel('显示名称 2').fill('Private Preview')
// Capacities live behind the row's own disclosure, as in the pi-ai form.
await settings.getByRole('button', { name: '容量 2' }).click()
await settings.getByLabel('上下文窗口 2').fill('131072')
await settings.getByLabel('最大输出 token 数 2').fill('64K')
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
await settings.getByRole('button', { name: '保存', exact: true }).click()
await customModelId.waitFor({ state: 'detached', timeout: 15_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('id: deepseek-v4-pro')
expect(document).toContain('id: private-preview')
expect(document).toContain('name: Private Preview')
expect(document).toContain('contextWindow: 131072')
expect(document).toContain('maxTokens: 64000')
expect(document).not.toContain('id: deepseek-v4-flash')
await page.keyboard.press('Escape')
// A connected Workspace is what puts a live composer — and its model
// trigger — on the page; the scaffold boots without one.
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true })
await modelTrigger.waitFor({ timeout: 10_000 })
await modelTrigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0)
await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
await assertFixtureInventory(
SNAPSHOT_DIR,
['missing.expected.md', 'models.expected.md', 'welcome.expected.md'],
)
})
})

View File

@@ -25,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// The waiting golden owns the decision card; the approved golden owns the
// transcript the approval leaves behind — the state the card cannot see.
const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
const MODE = webSnapshotMode()
@@ -82,9 +83,15 @@ describe('web e2e: plan review takeover round trip', () => {
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
await card.getByRole('button', { name: 'Approve' }).click()
@@ -100,6 +107,7 @@ describe('web e2e: plan review takeover round trip', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Card gone; regular input restored.
expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
@@ -108,6 +116,8 @@ describe('web e2e: plan review takeover round trip', () => {
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md',
])
})
})

View File

@@ -0,0 +1,103 @@
// Keyless browser regression for pwsh UI parity with bash: a seeded session
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
// api-proxy recomputes presentation views from logged args/result content)
// must render as a bash-shaped terminal card with the parsed exit-status
// pill — not the generic console-fenced card the pwsh presenter used to
// emit. The seed is authored, not recorded: its header line carries no `cwd`
// field (seedSession writes the session cwd itself, and a Windows temp path
// substituted into the header would not round-trip through its JSON parse),
// and no event references the workspace, so the lane replays on any host
// with a usable `pwsh` — the lane mounts the pwsh stack through an overlay
// (the shipped tree keeps the bash stack).
import { spawnSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/pwsh-terminal', import.meta.url))
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url))
const PROMPT = 'Run a PowerShell command that fails, then stop.'
const SEED_ID = 'pwsh-terminal-web-e2e'
const MODE = webSnapshotMode()
// The overlay swaps the shipped bash executor for @deepseek-ai/dsh-pwsh-local;
// a host without a usable `pwsh` cannot boot it, so the lane self-skips,
// mirroring the pwshOnly ACP scenarios. The probe follows the executor's own
// resolution (Program Files installs on Windows are found even when bare
// `pwsh` is not on PATH), the same judgment the tool-pwsh tests reuse; record
// mode skips the lane anyway, so the probe stays inert there.
const HAS_PWSH = MODE === 'record' ? false : spawnSync(
resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
{ encoding: 'utf8' },
).status === 0
describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
beforeAll(async () => {
const fixture = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(fixture), 'seed fixture must carry the single drive prompt').toEqual([PROMPT])
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
await seedSession(scaffold, fixture, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('renders the seeded pwsh call as a terminal card with the parsed exit pill', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
// Open the seeded session through content search: the sidebar groups
// sessions by workspace and its row order is world-dependent, while the
// search index covers the seeded log deterministically.
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
await search.fill('Run a PowerShell command')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
await result.click()
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 })
// The tool row is expand-gated: the settled bash-shaped row carries the
// shell-family variant, and the terminal card lives in the expanded body.
const row = page.locator('[data-tool="pwsh"]').first()
await row.waitFor({ timeout: 15_000 })
if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
const card = page.locator('[data-terminal]').first()
await card.waitFor({ timeout: 15_000 })
// The parsed exit pill replaces the `[exit code: 1]` marker in the output
// body — the bash tool's terminal presentation, not the generic fence.
const text = await card.textContent()
expect(text).toContain('exit code 1')
expect(text).toContain('Get-Item : Cannot find path')
expect(text).not.toContain('[exit code: 1]')
const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
// normalizeAria collapses the workspace basename with a '/' split, which
// misses Windows temp paths; collapse it here too (a no-op on POSIX) so
// the golden is platform-independent.
.split(scaffold.workspaceCwd.split(/[\\/]/).pop()!).join('{{workspace}}')
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
}, 60_000)
it('guards the lane fixture inventory', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'terminal-card.expected.md'])
})
})

View File

@@ -0,0 +1,20 @@
# The pwsh terminal-card lane swaps the shipped bash stack for the PowerShell
# twin: the bash executor row is disabled (patches cannot rename a row — `name`
# is a guard) and the pwsh executor + tool are inserted. The permission service
# refuses an unconfined executor by design (presets bundle a sandbox mode), so
# its row is disabled too — this lane renders a seeded session, never a
# permission decision. The seeded scenario renders the logged pwsh call/result
# through the real tools on replay; no command executes, but the composition
# must boot the pwsh executor, so the lane skips on hosts without a usable
# `pwsh`.
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: true
- id: permission
name: '@deepseek-ai/dsh-permission'
disabled: true
- insert:
- id: pwsh-local
name: '@deepseek-ai/dsh-pwsh-local'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'

View File

@@ -23,6 +23,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
// Final golden: the answered transcript — the question resolved into its tool
// round trip and the final reply, the state the composer goldens cannot see.
@@ -76,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => {
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
// This golden owns the stable question surface; the answered-state
// golden below owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
// Squeezed card: the option rows are the capped card's scroll content, so
@@ -155,6 +162,7 @@ describe('web e2e: resident question composer round trip', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Composer gone; regular input restored.
expect(await page.locator('[data-question-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
// Golden of the answered transcript: the ask_user_question round trip
// rendered as history (question tool row + DONE), composer takeover gone.
@@ -168,6 +176,7 @@ describe('web e2e: resident question composer round trip', () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
'ui.expected.md',
'sidebar.expected.md',
'composed.expected.md',
'answered.expected.md',
])

View File

@@ -32,6 +32,7 @@ const REMOVE = 'Queue item to remove'
const EDIT = 'Queue item to edit'
const EDITED = 'Edited queue item'
const TAIL = 'Queue item preserved after stop'
const WAKE = 'Wake the preserved queue'
/** Durable turn-end classifications observed by the scenario. */
function turnEndReasons(events: readonly SessionEvent[]): string[] {
@@ -63,13 +64,13 @@ describe('web e2e: queue row actions', () => {
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
const readyFile = join(overrideDir, '.hang-ready')
const nextReadyFile = join(overrideDir, '.next-hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
expect(recorded).toHaveLength(1)
const replay: ReplayEntry[] = [
{ kind: 'hang', readyFile },
{ kind: 'hang', readyFile: nextReadyFile },
recorded[0]!,
recorded[0]!,
recorded[0]!,
]
await writeFile(overridePath, JSON.stringify(replay))
@@ -86,7 +87,7 @@ describe('web e2e: queue row actions', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
const input = page.locator('textarea').first()
const settled = scaffold.whenTurnSettled()
const firstSettled = scaffold.whenTurnSettled()
await input.fill(ACTIVE_PROMPT)
await input.press('Enter')
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
@@ -157,19 +158,24 @@ describe('web e2e: queue row actions', () => {
).toBe(2)
await page.getByRole('button', { name: 'Stop generating' }).click()
await expect.poll(() => existsSync(nextReadyFile), { timeout: 15_000 }).toBe(true)
await page.getByText(TAIL, { exact: true }).waitFor()
await firstSettled
await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
.toBe(0)
await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
.toBe(1)
.toBe(2)
const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
await page.getByRole('button', { name: 'Stop generating' }).click()
const settled = scaffold.whenTurnSettled()
await input.fill(WAKE)
await input.press('Enter')
await settled
expect(turnEndReasons(sessionEvents)).toEqual(['aborted', 'aborted', 'completed'])
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user'))
.toHaveLength(3)
await expect.poll(() => turnEndReasons(sessionEvents), { timeout: 15_000 })
.toEqual(['aborted', 'completed', 'completed', 'completed'])
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])).toEqual([ACTIVE_PROMPT, EDITED, TAIL, WAKE])
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
}, 120_000)

View File

@@ -2,8 +2,8 @@
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
// Boots the REAL web composition — the shipped base plus web overlay through
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: normally disables the llm-deepseek row and
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
// harvests fixtures from live session memory), refresh (keyless replay that
@@ -56,7 +56,7 @@ import type {} from '@deepseek-ai/dsh-agent'
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
/**
@@ -379,26 +379,21 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
ctx,
workspaceCwd,
persistenceRoot,
// Barrier stack: the in-process turn/end identifies the session, then
// agent.whenIdle() covers the persistence flush (the idle flip follows
// the flush), and the caller's browser settled-poll comes last because
// host completion strictly precedes render.
// Barrier stack: the in-process turn/end identifies the session, its
// explicit flush makes the transcript durable, and the caller's browser
// settled-poll comes last because host completion strictly precedes render.
whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise<SessionId> {
return new Promise<SessionId>((resolveSettled, reject) => {
const timer = setTimeout(() => {
off()
reject(new Error(`no turn/end within ${timeoutMs}ms`))
}, timeoutMs)
const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
const off = ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'turn/end') return
clearTimeout(timer)
off()
const agent = ctx.agents.get(session.id)
if (agent === undefined) {
reject(new Error(`turn/end for ${session.id} but no live agent`))
return
}
agent.whenIdle().then(() => { resolveSettled(session.id) }, reject)
ctx.sessions.flush(session)
.then(() => { resolveSettled(session.id) }, reject)
})
})
},
@@ -481,15 +476,29 @@ export function fixtureUserPrompts(fixtureText: string): string[] {
* @param id - the seeded session id (stable for deterministic goldens).
* @returns the seeded id.
*/
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
/**
* Realize a recorded seed fixture against one scaffold: substitute the
* `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the
* scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
* price content exactly as the host will fold it) and still pass the result
* through {@link seedSession}.
* @param scaffold - the booted scaffold whose workspace the seed targets.
* @param fixtureText - the committed seed fixture text.
* @param id - the session id the seed is realized for.
* @returns the realized fixture text.
*/
export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, id: string): string {
const realized = fixtureText
.split('{{sessionId}}').join(id)
.split('{{cwd}}').join(scaffold.workspaceCwd)
const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
const rewritten = fixtureCwd === undefined
return fixtureCwd === undefined
? realized
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
const events = parseSessionLog(rewritten)
}
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id))
if (events.length === 0) throw new Error('seed fixture has no events')
const last = events[events.length - 1]!
// An open final turn would be mutated by resume's crash repair on first
@@ -523,8 +532,14 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
}
/**
* Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration
* volatility collapse to stable tokens.
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and
* decode-throughput volatility collapse to stable tokens.
*
* Throughput needs a token for the same reason durations do, and no fixture
* can supply one: the figure divides a replayed step's output tokens by the
* wall time the local run took to stream them, so it moves between two runs
* on one machine (measured 69 → 70 tok/s) and swings wildly on a fast replay
* (26333 tok/s for a 3 ms stream).
*/
function normalizeAria(snapshot: string, workspaceCwd: string): string {
// The session heading renders the workspace's basename, not the full
@@ -534,14 +549,17 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
.split(workspaceCwd).join('{{cwd}}')
.split(base).join('{{workspace}}')
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
// The optional space in `\d+m ?\d+s` covers both minute spellings: the
// stats line's compact `2m42s` and the message-chrome template's `2m 42s`.
.replace(
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m ?\d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
duration => duration.startsWith('~') ? duration : '{{duration}}',
)
.replace(
/约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g,
duration => duration.startsWith('约') ? duration : '{{duration}}',
)
.replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
// Message IconActions clocks widen by calendar day/year; collapse every
// shape so goldens stay stable across midnight and year boundaries.
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
@@ -592,9 +610,9 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string,
}
/**
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
* holds exactly the expected files and every committed JSONL is a scrub
* fixed-point without a run-local browser RPC id.
* Fixture-inventory guard: the scenario directory holds exactly the expected
* files and every committed JSONL is a scrub fixed-point without a run-local
* browser RPC id.
* @param dir - the scenario snapshot directory.
* @param expected - the exact expected file inventory.
*/

View File

@@ -16,11 +16,14 @@ import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import { join } from 'node:path'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
launchWebScaffold, realizeSeedFixture, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
@@ -41,24 +44,28 @@ const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and
* deterministic condition before seeding it cold, so the scenario pins the bug
* this change fixes — a landed compaction must not erase history the reader
* already saw — through the real host and the real browser.
* @param raw - the committed seed fixture text.
* @param raw - the seed fixture text, already realized (placeholder-free) so
* the shadow price below is computed from the exact strings the host folds.
* @param meter - the composed token meter; the appended `compact/summary`'s
* shadow price must be the exact heuristic price of the shadowed nodes, the
* way compact-basic derives it, because the token-meter projections subtract
* it verbatim.
* @returns the fixture with a compacted turn appended.
*/
function withCompaction(raw: string): string {
function withCompaction(raw: string, meter: TokenMeterService): string {
const lines = raw.trimEnd().split('\n')
const events = lines.slice(1).map(line => JSON.parse(line) as {
type: string
seq: number
time: number
surfaceOp?: unknown
data?: { turn?: unknown }
data?: { turn?: unknown; message?: unknown; content?: unknown; callId?: unknown; isError?: unknown }
})
const surfaceSeqs = events
.filter(event => event.surfaceOp === 'append'
&& (event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'))
|| event.type === 'tool/result'))
.map(event => event.seq)
const first = surfaceSeqs[0]
const last = surfaceSeqs.at(-1)
@@ -86,8 +93,33 @@ function withCompaction(raw: string): string {
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
at({ type: 'turn/start', data: { turn } })
const startSeq = at({ type: 'compact/start', data: { turn } })
// Load-bearing exactness: the projections subtract this count verbatim, so
// it must equal what the host's fold prices for these nodes. The estimator
// prices message CONTENT only, so a minimal wrapper per storage shape is
// exact — pre-identity rows carry bare `content` (the persistence read path
// upgrades them), a current row carries the full `message` envelope.
const priceRow = (row: (typeof events)[number]): number => {
if (row.data?.message !== undefined) {
const message = deriveEventMessage(row as unknown as SessionEvent)
return message === null ? 0 : meter.estimateMessage(message)
}
const content = row.data?.content as ContentBlock[]
if (row.type === 'tool/result') {
return meter.estimateMessage({
content: [{ type: 'tool-result', toolCallId: row.data?.callId, content, isError: row.data?.isError === true }],
} as unknown as Message)
}
// An empty-content assistant message derives no transcript entry.
if (row.type === 'assistant/message' && content.length === 0) return 0
return meter.estimateMessage({ content } as unknown as Message)
}
const shadowedTokenCount = surfaceSeqs.reduce((total, surfaceSeq) => {
const event = events.find(candidate => candidate.seq === surfaceSeq)
if (event === undefined) throw new Error(`seeded-history compaction: shadowed seq ${surfaceSeq} is not in the seed`)
return total + priceRow(event)
}, 0)
const summarySeq = at({
type: 'compact/summary',
data: {
@@ -97,7 +129,7 @@ function withCompaction(raw: string): string {
}],
shadowedRange: { start: first, end: last },
shadowedSeqs: surfaceSeqs,
shadowedTokenCount: 10_000,
shadowedTokenCount,
provider: 'snapshot',
model: 'snapshot-compactor',
},
@@ -138,7 +170,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, withCompaction(raw), SEED_ID)
const meter = scaffold.ctx.get('tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -216,7 +251,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
agent.inject(createUserMessage({
agent.session.append('user/message', createUserMessage({
content: [{
type: 'text',
text: '<system-reminder>\n'
@@ -227,6 +262,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
}],
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [{
action: 'set',
@@ -235,18 +271,20 @@ describe('web e2e: seeded history renders through cold resume', () => {
digest: 'context-injection-browser-snapshot',
}],
},
}))
await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 })
}), { surfaceOp: 'append' })
// The header names the producer the durable source records, so the
// reconciled instruction file is readable without expanding the row.
await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
.waitFor({ timeout: 10_000 })
}, 60_000)
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
await page.getByRole('button', {
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the selected id, but no catalog
// adapter exists to provide its presentation name.
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the routed id, but without an
// advertised catalog row the selector prompts for a listed replacement.
await page.getByRole('button', { name: 'Select model', exact: true })
.waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
@@ -254,7 +292,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
const disclosure = page.getByRole('button', { name: 'Context injection' })
const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
const collapsedIcon = disclosure.locator('svg').first()
const collapsedIconBox = await collapsedIcon.boundingBox()
@@ -265,6 +303,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const body = page.locator('[data-context-injection-body]')
await body.waitFor({ timeout: 5_000 })
// The instructions form names the file it reconciled above the text, and
// the text keeps the framing the model read rather than a cleaned excerpt.
expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded'])
expect(await body.locator('[data-context-text]').innerText()).toContain('<system-reminder>')
const headerBox = await disclosure.boundingBox()
const bodyBox = await body.boundingBox()
if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable')
@@ -357,21 +399,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('fits short injected context without a scrollport', async () => {
it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => {
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
agent.inject(createUserMessage({
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Short injected context.' }],
source: { kind: 'plugin', plugin: 'fixture' },
}))
}), { surfaceOp: 'append' })
const disclosures = page.getByRole('button', { name: 'Context injection' })
await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2)
const disclosure = disclosures.nth(1)
const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true })
await disclosure.waitFor({ timeout: 10_000 })
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const body = page.locator('[data-context-injection-body]')
// The instructions row above stays expanded from the geometry case; the
// opaque body is the one without a declared form.
const body = page.locator('[data-context-injection-body]:not([data-context-form])')
const bodyBox = await body.boundingBox()
if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
expect(bodyBox.height).toBeLessThan(141)

View File

@@ -61,6 +61,30 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
await openDocument.waitFor({ timeout: 10_000 })
let openRequests = 0
await page.route('**/api/settings.openDocument', async (route) => {
const envelope = route.request().postDataJSON() as {
rpcId: string
payload: Record<string, never>
}
expect(envelope.payload).toEqual({})
openRequests += 1
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
type: 'server-response',
rpcId: envelope.rpcId,
result: { ok: true, value: { opened: true } },
}),
})
})
await openDocument.click()
await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
await page.unroute('**/api/settings.openDocument')
// Golden of the freshly opened dialog (default zh, General active).
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)

View File

@@ -196,16 +196,19 @@ describe('dsh web keyless CLI smoke', () => {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
}
let resolveProviderRequest!: (request: NativeProviderRequest) => void
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
resolveProviderRequest = resolve
let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void
const requests: NativeProviderRequest[] = []
const providerRequests = new Promise<NativeProviderRequest[]>((resolve) => {
resolveProviderRequests = resolve
})
const provider = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
const parsed = JSON.parse(body) as NativeProviderRequest
if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed)
if (requests.length === 1) resolveProviderRequests(requests)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
@@ -244,14 +247,16 @@ describe('dsh web keyless CLI smoke', () => {
mode: 'queue',
content: [{ type: 'text', text: 'go' }],
})
const captured = await Promise.race([
providerRequest,
const capturedRequests = await Promise.race([
providerRequests,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
}),
])
expect(captured.messages?.some(message =>
message.role === 'user' && message.content?.includes('<available_skills>'))).toBe(false)
const captured = capturedRequests[0]
if (captured === undefined) {
throw new Error('provider did not receive the workspace projection request')
}
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')

View File

@@ -10,14 +10,14 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- 'button "Failed Bash Error: command aborted" [expanded]':
- text: Context injection @deepseek-ai/dsh-system-prompt
- 'button "Failed Bash Error: tool call aborted" [expanded]':
- img
- text: "Failed Bash Error: command aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted"
- text: "Failed Bash Error: tool call aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted"
- button "Inspect"
- 'button "Failed Bash Error: tool call aborted before dispatch"':
- img
@@ -26,8 +26,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -44,5 +44,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "7% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 7% of 128K Cache hit 52% Input 17.2K tok · Output 252 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 52% Input 17.2K tok · Output 252 tok

View File

@@ -0,0 +1,37 @@
# Input card position across the Chat and Trajectory tabs
## Wide viewport (1680px, card at its cap)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Narrow viewport (800px, card shrinking with the column)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Wide viewport, reservation removed in the page (control)
- Chat: scrollbar-gutter auto, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
- Trajectory scroller scrolls: false
- Trajectory reserved band: 0px
- input card left edge moves between tabs: 4px
- input card right edge moves between tabs: 4px
- input card width changes between tabs: 0px

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to:":
- img
- img
@@ -51,7 +51,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -59,5 +59,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "13% of context used"
- button "Send message" [disabled]
- text: 1 turns · 4 steps Tool call {{duration}} Context 13% of 128K Cache hit 77% Input 66.5K tok · Output 312 tok
- text: 1 turns · 4 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 77% Input 66.5K tok · Output 312 tok

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img
@@ -31,7 +31,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -39,5 +39,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 99% Input 15.7K tok · Output 111 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok

View File

@@ -28,6 +28,7 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
- tooltip "Commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash

View File

@@ -30,8 +30,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode on, press to turn off": Plan
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: Details

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
@@ -23,7 +23,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -31,5 +31,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 21 tok
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok

View File

@@ -10,17 +10,17 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- text: Stopped
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- status:
- text: This turn failedAPI key is invalid
- code: AUTH

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- textbox "Message the agent"

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- group:
- status: Retried model request (1/2) · {{duration}}
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
@@ -25,7 +25,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -33,5 +33,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 79 tok
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 79 tok

View File

@@ -0,0 +1,52 @@
- banner:
- navigation "Session hierarchy":
- button "CJK strong emphasis" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Render adjacent CJK strong emphasis. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "CJK strong emphasis" [level=2]
- paragraph:
- strong: 注意:
- text: 内容
- paragraph:
- strong: "Notice:"
- text: 内容
- paragraph:
- strong: 事件中间件waterfall
- text: 实现
- paragraph:
- strong: 事件中间件(waterfall)
- text: 实现
- paragraph:
- strong: 句号。
- text: 后续
- paragraph:
- strong: Period.
- text: 后续
- paragraph:
- strong: 提醒!
- text: 继续
- paragraph:
- strong: Warning!
- text: 继续
- paragraph: CJK_STRONG_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok

View File

@@ -19,13 +19,13 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok

View File

@@ -0,0 +1,43 @@
- banner:
- navigation "Session hierarchy":
- button "Inline code links" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Show the local preview URL. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Inline code links" [level=2]
- paragraph:
- text: "Preview:"
- code:
- link "{{linkUrl}}":
- /url: {{linkUrl}}
- paragraph:
- text: "Standard:"
- link "Open preview":
- /url: {{linkUrl}}
- paragraph:
- text: "Command:"
- code: curl {{linkUrl}}
- paragraph:
- text: "Unsafe:"
- code: javascript:alert(1)
- paragraph: INLINE_CODE_LINK_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok

View File

@@ -0,0 +1,47 @@
- banner:
- navigation "Session hierarchy":
- button "Math rendering" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Render this mathematical proof. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Math rendering" [level=2]
- paragraph:
- text: Inline dollar
- math: θ
- text: and backslash
- math: 1 5
- text: .
- math: π 4 < θ < π 2
- math: θ ∈ ( π 4 , π 2 ) . (1)
- table:
- rowgroup:
- row "Symbol Value":
- columnheader "Symbol"
- columnheader "Value"
- rowgroup:
- row:
- cell:
- math: θ
- cell:
- math: 1 5
- paragraph: MATH_RENDERING_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -20,7 +20,7 @@
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn 7/25 {{clock}}Ran for {{duration}}
- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Read a.txt":
- img
- img
@@ -46,13 +46,13 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}Ran for {{duration}}
- text: 7/25 {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok

View File

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
@@ -17,4 +18,6 @@
- text: minimax-cn
- button "编辑"
- button "删除"
- button "+ 添加提供方"
- button "添加提供方":
- img
- text: 添加提供方

View File

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭

View File

@@ -5,7 +5,7 @@
- img
- searchbox "Search trajectory"
- region "Trajectory timeline":
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
- table:
- rowgroup:
- row "SYSTEM, Initial System Prompt":

View File

@@ -0,0 +1,71 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- button "编辑"
- text: DeepSeek deepseek-official API 密钥
- textbox "API 密钥":
- /placeholder: 已配置——输入新值可替换
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com
- text: 推理强度
- combobox "推理强度":
- option "默认" [selected]
- option "off"
- option "high"
- option "max"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: deepseek-v4-pro
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: DeepSeek-V4-Pro
- button "容量 1":
- img
- button "删除模型 1":
- img
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: private-preview
- textbox "显示名称 2":
- /placeholder: 显示名称
- text: Private Preview
- button "容量 2" [expanded]:
- img
- button "删除模型 2":
- img
- text: 上下文窗口
- textbox "上下文窗口 2":
- /placeholder: 1M
- text: "131072"
- text: 最大输出 token 数
- textbox "最大输出 token 数 2":
- /placeholder: 256K
- text: 64K
- button "添加模型":
- img
- text: 添加模型
- button "取消"
- button "保存"
- button "添加提供方":
- img
- text: 添加提供方

View File

@@ -5,16 +5,16 @@
- tab "Chat" [selected]
- tab "Trajectory"
- img
- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."':
- img
- img
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -44,5 +44,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "4% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 4% of 128K Cache hit 51% Input 10.2K tok · Output 346 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 10.2K tok · Output 346 tok

View File

@@ -0,0 +1 @@
- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]'

View File

@@ -0,0 +1,19 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747}
{"type":"turn/start","seq":0,"time":1784974200000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784974200001,"data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784974200002,"data":{"title":"Run a PowerShell command","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784974200010,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784974200011,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784974200200,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":6,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Run the failing pwsh command."}}}
{"type":"assistant/chunk","seq":7,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Run the failing pwsh command."}}}}
{"type":"assistant/chunk","seq":8,"time":1784974200300,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":9,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_pwsh_fail_0001","name":"pwsh","argumentsDelta":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}
{"type":"assistant/chunk","seq":10,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}}
{"type":"assistant/chunk","seq":11,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}}}}
{"type":"assistant/chunk","seq":12,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":13,"time":1784974200310,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
{"type":"tool/call","seq":14,"time":1784974200311,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}
{"type":"tool/result","seq":15,"time":1784974200500,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","seq":16,"time":1784974200501,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":17,"time":1784974200501,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,3 @@
- text: Failed {{workspace}} Get-Item missing.txt exit code 1
- button "Copy"
- text: "[stderr] Get-Item : Cannot find path 'missing.txt' because it does not exist."

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img
@@ -31,7 +31,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -39,5 +39,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "3% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 3% of 128K Cache hit 95% Input 8.6K tok · Output 180 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 95% Input 8.6K tok · Output 180 tok

View File

@@ -0,0 +1 @@
- treeitem "Waiting for answer Use the ask_user_question tool to now" [selected]

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- button "2 queued messages" [disabled] [expanded]
@@ -30,6 +30,7 @@
- textbox "Edit queued message": Edited queue item
- button "Save queued message":
- img
- tooltip "Save queued message"
- button "Cancel editing":
- img
- textbox "Message the agent"

View File

@@ -8,22 +8,18 @@
- img
- img
- text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
- button "Context injection":
- button "Context injection goal":
- img
- img
- text: Context injection
- button "Context injection":
- text: Context injection goal
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- button "Context injection":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- region "To-dos":
- button "To-dos 1/2 tasks · 1 in progress"
- button "To-dos 1 completed · 1 in progress"
- img
- text: Ongoing Goal Keep the composer context panels aligned
- button "Pause goal":

View File

@@ -10,32 +10,35 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- text: Stopped
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}} Edited queue item {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- paragraph: partial
- status: Deep diving...
- text: {{clock}} Ran for {{duration}}
- button "2 queued messages" [expanded]
- list:
- listitem:
- text: Edited queue item
- button "Edit queued message":
- img
- tooltip "Edit queued message"
- button "Remove queued message":
- img
- button "Steer queued message" [disabled]:
- img
- listitem:
- text: Queue item preserved after stop
- button "Edit queued message":
- img
- button "Remove queued message":
- img
- button "Steer queued message":
- button "Steer queued message" [disabled]:
- img
- textbox "Message the agent"
- button "Commands":
@@ -44,5 +47,5 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- list:
@@ -21,6 +21,7 @@
- text: Edited queue item
- button "Edit queued message":
- img
- tooltip "Edit queued message"
- button "Remove queued message":
- img
- button "Steer queued message":

View File

@@ -7,7 +7,7 @@ line=138: export function SearchBlock(props: SearchBlockProps) {
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
line=35: const search = searchCardModel(block)
line=52: search={search}
line=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
expand=… 其余 4 行
recovery=Found 9 of 42 matches
@@ -22,6 +22,6 @@ packages/client/ui-conversation/src/client/toolviews/search-row.tsx
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
Line 35: const search = searchCardModel(block)
Line 52: search={search}
Line 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)

View File

@@ -33,22 +33,22 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}Ran for {{duration}}
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- button "Context injection AGENTS.md":
- img
- img
- text: Context injection
- text: Context injection AGENTS.md
- img
- text: permission preset read-only
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -33,20 +33,20 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}Ran for {{duration}}
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- button "Context injection AGENTS.md":
- img
- img
- text: Context injection
- text: Context injection AGENTS.md
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭

View File

@@ -28,8 +28,8 @@
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb, pointer over the list: rgb(60, 60, 61)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(84, 85, 87)
- --dsh-scrollbar-thumb, pointer over the list: rgb(84, 85, 87)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(101, 103, 107)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
@@ -24,7 +24,7 @@
- img
- text: Ask question waiting
- status: Deep diving...
- text: "Interjection: include the word BANANA in your final reply."
- text: "Interjection Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- region "Ready to continue?":

View File

@@ -15,7 +15,7 @@
{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
{"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}
{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"user/message","seq":91,"time":1785004181867,"data":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
@@ -22,7 +22,7 @@
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
@@ -37,7 +37,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -45,5 +45,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 98% Input 15.8K tok · Output 156 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 156 tok

View File

@@ -15,10 +15,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}} Now give the same explanation to a human reader. {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
@@ -43,10 +43,11 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "6% of context used"
- button "Send message" [disabled]
- text: 2 turns · 2 steps Context 6% of 128K Cache hit 99% Input 15.6K tok · Output 158 tok
- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok

View File

@@ -10,10 +10,10 @@
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection":
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Search DeepSeek Harness snapshot search":
- img
- img
@@ -23,7 +23,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
@@ -31,5 +31,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 0% of 128K Cache hit 0% Input 22 tok · Output 7 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 22 tok · Output 7 tok

View File

@@ -21,7 +21,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// Two goldens pin the transient Host projection and its durable handoff: the
// mid-turn state renders accepted steering from session/queue while the
// question blocks admission, then the settled state renders the same message
// from steering/message beside the reply that obeys it.
// from user/message beside the reply that obeys it.
const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const MODE = webSnapshotMode()
@@ -45,6 +45,12 @@ function assistantText(events: SessionEvent[]): string {
.join('')
}
/** Claimed user messages whose payload contains the exact scenario text. */
function claimedMessages(events: readonly SessionEvent[], text: string): SessionEvent<'user/message'>[] {
return events.filter((event): event is SessionEvent<'user/message'> =>
event.type === 'user/message' && JSON.stringify(event.data.content).includes(text))
}
describe('web e2e: mid-turn steering lands durably and visibly', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -74,8 +80,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
if (MODE !== 'record') {
// The steer must NOT be a user/message — it lands as steering/message.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
// The steer lands as a durable user/message, so the inventory holds
// both the opening prompt and the later same-turn steer.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
@@ -112,7 +119,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
}
// Answer the composer; the tool result closes the step, the loop drains
// the steer as steering/message, and the steered continuation runs the
// the steer as user/message, and the steered continuation runs the
// final model call.
await composer.getByRole('radio', { name: 'Yes' }).click()
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
@@ -124,15 +131,14 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
// Fixture honesty: a recording where the live model ignored the steer
// would replay as a vacuous scenario — reject it and re-record instead.
const recorded = parseSessionLog(await readFile(FIXTURE, 'utf8'))
expect(recorded.filter(e => e.type === 'steering/message')).toHaveLength(1)
expect(claimedMessages(recorded, STEER)).toHaveLength(1)
expect(assistantText(recorded)).toContain('BANANA')
return
}
// Durable: exactly one steering/message, inside turn 1, carrying the text.
const steerEvents = sessionEvents.filter(e => e.type === 'steering/message')
// Durable: exactly one claimed user/message carrying the steering text.
const steerEvents = claimedMessages(sessionEvents, STEER)
expect(steerEvents).toHaveLength(1)
expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
expect(JSON.stringify(steerEvents[0])).toContain('BANANA')
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
expect(turnEnds).toHaveLength(1)
@@ -182,7 +188,7 @@ describe('web e2e: composer shortcut steers directly', () => {
it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering'))
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER])
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled(30_000)
@@ -203,9 +209,8 @@ describe('web e2e: composer shortcut steers directly', () => {
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
await settled
const steerEvents = sessionEvents.filter(event => event.type === 'steering/message')
const steerEvents = claimedMessages(sessionEvents, STEER)
expect(steerEvents).toHaveLength(1)
expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await pendingSteering.count()).toBe(0)
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 })
@@ -259,7 +264,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText })
await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 })
expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0)
expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0)
expect(claimedMessages(sessionEvents, queuedText)).toHaveLength(0)
// Remove the asserted Queue row, then finish the recorded question turn
// so replay teardown still proves that every fixture call was consumed.

View File

@@ -88,6 +88,28 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
.waitFor({ timeout: 15_000 })
}
/**
* {@link connectFreshWorkspace} over the product default Chinese locale: the
* English helper's anchors assume the locale every other scenario boots, so a
* scenario that deliberately keeps zh needs the localized picker copy.
* @param page - the browser page under test.
* @param root - workspace parent directory.
* @param name - directory created under `root` and connected.
*/
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: '打开', exact: true }).click()
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
.waitFor({ timeout: 15_000 })
}
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
export async function saveFailureShot(page: Page, name: string): Promise<void> {
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))

View File

@@ -0,0 +1,309 @@
// Browser contract for the tail-paged, virtualized Trajectory ledger. The
// scenario proves that semantic row identity survives an older-page prepend,
// DOM mounting stays bounded, and every scroll range remains reachable.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SESSION_ID = 'trajectory-virtualization-e2e'
const FIXTURE = createChatScrollFixture({
markerPrefix: 'TRAJECTORY_VIRTUAL',
title: 'TRAJECTORY_VIRTUAL long ledger',
turns: 88,
})
const MAX_MOUNTED_ROWS = 160
const GEOMETRY_TOLERANCE = 2
const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED'
const STREAM_TEXT = Array.from(
{ length: 80 },
(_, index) => `stream fragment ${String(index + 1).padStart(2, '0')} `,
).join('') + STREAM_MARKER
const STREAM_CHUNKS: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
...Array.from({ length: 80 }, (_, index): StreamChunk => ({
type: 'text-delta',
index: 0,
text: `stream fragment ${String(index + 1).padStart(2, '0')} `,
})),
{ type: 'text-delta', index: 0, text: STREAM_MARKER },
{ type: 'block-end', index: 0, block: { type: 'text', text: STREAM_TEXT } },
{ type: 'usage', usage: { inputTokens: 2_700, outputTokens: 240 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
interface ScrollGeometry {
readonly clientHeight: number
readonly scrollHeight: number
readonly scrollTop: number
}
interface RowAnchor {
readonly key: string
readonly top: number
}
async function openSeed(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
await result.click()
await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
.last()
.waitFor({ timeout: 30_000 })
}
async function openTrajectory(page: Page): Promise<void> {
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
const pane = page.locator('[data-trajectory-scroll]')
await pane.waitFor({ timeout: 30_000 })
await page.locator('[data-trajectory-scroll] table[data-scroll-ready="true"]')
.waitFor({ timeout: 30_000 })
}
async function logicalRows(page: Page): Promise<number> {
const raw = await page.locator('[data-trajectory-scroll] table').getAttribute('aria-rowcount')
if (raw === null || !/^\d+$/.test(raw)) {
throw new Error(`trajectory table has invalid aria-rowcount ${JSON.stringify(raw)}`)
}
return Number(raw)
}
async function mountedRows(page: Page): Promise<number> {
return page.locator('[data-trajectory-scroll] tr[data-trajectory-row-key]').count()
}
async function geometry(page: Page): Promise<ScrollGeometry> {
return page.locator('[data-trajectory-scroll]').evaluate(host => ({
clientHeight: host.clientHeight,
scrollHeight: host.scrollHeight,
scrollTop: host.scrollTop,
}))
}
async function nextPaint(page: Page): Promise<void> {
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))
}))
}
async function scrollToRatio(page: Page, ratio: number): Promise<void> {
await page.locator('[data-trajectory-scroll]').evaluate((host, value) => {
const maximum = Math.max(0, host.scrollHeight - host.clientHeight)
host.scrollTop = Math.round(maximum * value)
host.dispatchEvent(new Event('scroll'))
}, ratio)
await nextPaint(page)
}
async function firstVisibleRow(page: Page): Promise<RowAnchor> {
return page.locator('[data-trajectory-scroll]').evaluate((host) => {
const hostBox = host.getBoundingClientRect()
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
const row = rows.find((candidate) => {
const box = candidate.getBoundingClientRect()
return candidate.dataset.requestOnly !== 'true'
&& box.bottom > hostBox.top
&& box.top < hostBox.bottom
})
const key = row?.dataset.trajectoryRowKey
if (row === undefined || key === undefined) {
throw new Error('trajectory scrollport has no visible semantic row')
}
return { key, top: row.getBoundingClientRect().top - hostBox.top }
})
}
async function rowTop(page: Page, key: string): Promise<number | null> {
return page.locator('[data-trajectory-scroll]').evaluate((host, targetKey) => {
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
const row = rows.find(candidate => candidate.dataset.trajectoryRowKey === targetKey)
return row === undefined
? null
: row.getBoundingClientRect().top - host.getBoundingClientRect().top
}, key)
}
async function loadToFirstTurn(page: Page): Promise<void> {
const marker = FIXTURE.markers.user(1)
for (let attempt = 0; attempt < 12; attempt += 1) {
await scrollToRatio(page, 0)
if (await page.getByText(marker, { exact: false }).count() > 0) return
const before = await logicalRows(page)
await expect.poll(async () => ({
marker: await page.getByText(marker, { exact: false }).count() > 0,
rows: await logicalRows(page),
}), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before })
}
throw new Error('trajectory did not reach the first turn after twelve older-page requests')
}
describe('web e2e: Trajectory virtualization over tail-paged history', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let replayDir: string
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-trajectory-virtualization-'))
const replayFixture = join(replayDir, 'session.jsonl')
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayFixture, FIXTURE.log)
await writeFile(replayOverride, JSON.stringify([{
kind: 'chunks',
chunks: STREAM_CHUNKS,
} satisfies ReplayEntry]))
scaffold = await launchWebScaffold({
paceMs: 10,
replayFixture,
replayOverride,
})
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
await rm(replayDir, { recursive: true, force: true })
})
it.skipIf(MODE === 'record')('retains identity on prepend and reaches the bounded virtual range', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-virtualization'))
await openSeed(page)
let held = false
let releaseHistory: () => void = () => {}
let finishHeldRequest: () => void = () => {}
const gate = new Promise<void>((resolve) => { releaseHistory = resolve })
const heldRequestFinished = new Promise<void>((resolve) => { finishHeldRequest = resolve })
await page.route('**/api/session.history', async (route) => {
const request = route.request().postDataJSON() as {
method?: string
payload?: { beforeSeq?: number }
}
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
held = true
await gate
try {
await route.continue()
} finally {
finishHeldRequest()
}
return
}
await route.continue()
})
try {
await openTrajectory(page)
const initialRows = await logicalRows(page)
expect(initialRows).toBeGreaterThan(0)
expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0)
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
await scrollToRatio(page, 0)
await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
const anchor = await firstVisibleRow(page)
const selectedRow = page.locator(
`[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`,
)
await selectedRow.click()
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
.toBe('true')
releaseHistory()
await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows)
await nextPaint(page)
await expect.poll(async () => {
const top = await rowTop(page, anchor.key)
return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top)
}, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
.toBe('true')
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
await loadToFirstTurn(page)
await expect.poll(
() => page.getByText(FIXTURE.markers.user(1), { exact: false }).count(),
{ timeout: 10_000 },
).toBeGreaterThan(0)
const fullRows = await logicalRows(page)
await scrollToRatio(page, 0.5)
const middle = await geometry(page)
const maximum = middle.scrollHeight - middle.clientHeight
expect(middle.scrollTop).toBeGreaterThan(maximum * 0.25)
expect(middle.scrollTop).toBeLessThan(maximum * 0.75)
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
expect(await mountedRows(page)).toBeLessThan(fullRows)
await scrollToRatio(page, 1)
await expect.poll(async () => {
const value = await geometry(page)
return value.scrollHeight - value.clientHeight - value.scrollTop
}, { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
await expect.poll(
() => page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).count(),
{ timeout: 10_000 },
).toBeGreaterThan(0)
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
const trajectoryScroll = page.locator('[data-trajectory-scroll]')
await trajectoryScroll.evaluate((host) => {
const measuredWindow = window as Window & { __trajectoryScrollCalls?: number }
measuredWindow.__trajectoryScrollCalls = 0
const original = host.scrollTo.bind(host)
const trackedScrollTo = (...args: [ScrollToOptions?] | [number, number]) => {
measuredWindow.__trajectoryScrollCalls = (measuredWindow.__trajectoryScrollCalls ?? 0) + 1
Reflect.apply(original, host, args)
}
host.scrollTo = trackedScrollTo as typeof host.scrollTo
})
const settled = scaffold.whenTurnSettled()
const input = page.locator('textarea').first()
await input.fill('Stream one deterministic response while Trajectory remains visible.')
await input.press('Enter')
await settled
await page.getByText('stream fragment 01', { exact: false }).waitFor({ timeout: 30_000 })
await nextPaint(page)
const streamingScrollCalls = await trajectoryScroll.evaluate(() => {
return (window as Window & { __trajectoryScrollCalls?: number })
.__trajectoryScrollCalls ?? 0
})
expect(streamingScrollCalls).toBeLessThanOrEqual(5)
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
expect({
pageErrors: tripwire.pageErrors,
warnings: tripwire.warnings,
}).toEqual({ pageErrors: [], warnings: [] })
} finally {
releaseHistory()
if (held) await heldRequestFinished
await page.unroute('**/api/session.history')
}
}, 180_000)
})

View File

@@ -90,9 +90,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
{ timeout: 10_000 },
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; the reuse path (same
// canonical cwd already has a blank session) creates no agent, so callers
// opt in only where a fresh attach is possible.
// settle before a test may delete the registration; re-registration after
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
// path is gone), so callers opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
@@ -251,8 +251,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
// a supported reversible flow. It creates a fresh Workspace id and does
// NOT re-adopt the retained (non-blank) Session; the New Session flow
// mints a fresh blank session and attaches it to the new registration
// (the old cwd-only blank reuse is gone, so the account is never empty).
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
@@ -261,7 +263,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
expect(reregistered?.id).toBeDefined()
expect(reregistered?.id).not.toBe(workspace.id)
expect(reregistered?.sessionIds).toEqual([])
await expect.poll(
() => reregistered?.sessionIds ?? [],
{ timeout: 10_000 },
).not.toEqual([])
expect(reregistered?.sessionIds).not.toContain(SEED_ID)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')

View File

@@ -31,6 +31,8 @@
"tests/plan-review.e2e.ts",
"tests/steering.e2e.ts",
"tests/navigation-panes.e2e.ts",
"tests/chat-scroll-fixture.ts",
"tests/trajectory-virtualization.e2e.ts",
"tests/lifecycle-chrome.e2e.ts",
"tests/details-session-lifecycle.e2e.ts",
"tests/settings-chrome.e2e.ts",
@@ -48,6 +50,9 @@
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/markdown-cjk-strong.e2e.ts",
"tests/markdown-inline-code-links.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/permission-policy-context.e2e.ts",
@@ -61,7 +66,9 @@
"tests/chat-scroll-contract.e2e.ts",
"tests/chat-long-interactions.e2e.ts",
"tests/chat-continuous-conversation.e2e.ts",
"tests/complex-history.perf.ts"
"tests/composer-tab-geometry.e2e.ts",
"tests/complex-history.perf.ts",
"tests/pwsh-terminal.e2e.ts"
],
"references": [
{