Merge remote-tracking branch 'github/master' into xtr/trajectory-timeline-polish

# Conflicts:
#	packages/client/ui-trajectory/src/client/timeline.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
_Kerman
2026-07-31 20:01:16 +08:00
847 changed files with 94678 additions and 7001 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: c36a75fc61fd7118f48c9b68be3144177df19534
README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f
README.md: 43524950ae7d3379406a0c859b6449f0697d204b
README.zh.md: 01954f1229a6f4d076155d42566ed29d568631ca

View File

@@ -3,7 +3,7 @@
English | [中文](README.zh.md)
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
The TUI surface:
@@ -12,19 +12,49 @@ The TUI surface:
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it and pins `native`.
[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry.
From a source checkout, start this minimal Web profile with:
```sh
pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
```
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout:
```yaml
- insert:
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
env:
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
```
The model then sees `mcp__github__*`. See the [mcp-client README](../../packages/mcp/mcp-client/README.md) for the Streamable HTTP transport and the full field table.
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:

View File

@@ -3,7 +3,7 @@
[English](README.md) | 中文
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``meta` 子命令是以本 checkout 为 workspace 的同一个 TUI`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。凡与默认界面不共享任何选项的子命令`upgrade``web``meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``--dump-config``--dump-default-config``meta` 子命令是以本 checkout 为 workspace 的同一个 TUI`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。凡与默认界面不共享任何选项的子命令`upgrade``web``meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
TUI 界面:
@@ -12,19 +12,49 @@ TUI 界面:
-**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`
- 注册裸 `/compact`agent 空闲时即使未达到自动压力也会摘要有效的较早历史该命令拒绝参数并只在独立替换标记对持久化后报告成功。压缩compaction期间提交的提示词保留其队列身份并在该检查点之后启动注入的上下文仍保持可见。
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config``-p``--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
`dsh --dump-config``dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include``applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、CLI 标志补丁是每次调用的事实位于配置树之外不会出现。dump 标志会拒绝仅用于启动的标志(`-p``--resume``--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)
每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式`native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 线路)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam——Loader 组合是静态的,因此该设置作用于整个进程——待 Web UI 负责逐会话工具模式选择后便会移除TUI 界面会忽略该变量并固定为 `native`
[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个可选启用的 `dsh web --config` 覆盖层:它保留已交付的 Web 宿主、浏览器、Workspace、持久化与权限组合同时将默认的原生模型界面精简为以所有者为作用域的持久 `bash` 以及 `str_replace_editor`。PTY 后端和编辑器分别消费现有的 Web 沙箱与文件系统提供方。持久 shell 处于打开状态时,会阻止所属会话更改权限模式;因此,在较宽权限下创建的 shell 无法在降权后继续存活。`DSH_TOOLS_MODE` 仍控制由此得到的双工具注册表采用原生Code Mode 呈现。
在源码 checkout 中,用以下命令启动这个精简 Web profile
```sh
pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
```
每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs``DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector`DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0``false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml``--config` 覆盖层挂载你自己的服务器:
```yaml
- insert:
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
env:
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
```
模型随后会看到 `mcp__github__*`。Streamable HTTP 传输与完整字段表见 [mcp-client README](../../packages/mcp/mcp-client/README.md)。
## 安装(开发机)
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout因此代码更改会在下次启动时生效无需构建

View File

@@ -76,6 +76,8 @@ flowchart LR
cfg --> plugin_tui_token_meter
plugin_tui_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
cfg --> plugin_tui_compact_basic
plugin_tui_command_compact["command-compact<br/>@deepseek-ai/dsh-command-compact"]
cfg --> plugin_tui_command_compact
plugin_tui_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
cfg --> plugin_tui_subagent
plugin_tui_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
@@ -96,6 +98,28 @@ flowchart LR
cfg --> plugin_tui_spill_local
plugin_tui_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
cfg --> plugin_tui_spill_policy
plugin_tui_session_checkpoint_policy["session-checkpoint-policy<br/>@deepseek-ai/dsh-session-checkpoint-policy"]
cfg --> plugin_tui_session_checkpoint_policy
plugin_tui_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"]
cfg --> plugin_tui_tool_result_prune
plugin_tui_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_tui_tool_todo
plugin_tui_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
cfg --> plugin_tui_tool_goal
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
cfg --> plugin_tui_tool_ralph
plugin_tui_tool_session_query["tool-session-query<br/>@deepseek-ai/dsh-tool-session-query"]
cfg --> plugin_tui_tool_session_query
plugin_tui_tool_str_replace_editor["tool-str-replace-editor<br/>@deepseek-ai/dsh-tool-str-replace-editor"]
cfg --> plugin_tui_tool_str_replace_editor
plugin_tui_repeat_tool_guard["repeat-tool-guard<br/>@deepseek-ai/dsh-repeat-tool-guard"]
cfg --> plugin_tui_repeat_tool_guard
plugin_tui_web["web<br/>@deepseek-ai/dsh-web"]
cfg --> plugin_tui_web
plugin_tui_web_search_deepseek["web-search-deepseek<br/>@deepseek-ai/dsh-web-search-deepseek"]
cfg --> plugin_tui_web_search_deepseek
plugin_tui_tool_web["tool-web<br/>@deepseek-ai/dsh-tool-web"]
cfg --> plugin_tui_tool_web
plugin_tui_tools["tools<br/>@deepseek-ai/dsh-tools"]
cfg --> plugin_tui_tools
plugin_tui_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
@@ -144,6 +168,7 @@ flowchart LR
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
| `command-compact` | `@deepseek-ai/dsh-command-compact` |
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
@@ -154,6 +179,17 @@ flowchart LR
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` |
| `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` |
| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` |
| `web` | `@deepseek-ai/dsh-web` |
| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
| `tools` | `@deepseek-ai/dsh-tools` |
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |

View File

@@ -84,8 +84,7 @@
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js >-
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
root: !!js dshHomePath('sessions')
# TUI consumes this shared session capability. Its launcher supplies a unique
# process-local path; other surfaces repoint or disable the row in their
@@ -105,7 +104,9 @@
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
# process out (the launchers patch the row disabled; config cannot disable
# a row). The exporter/processor values bound the shutdown drain to ~1s
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
# random UUID; delete the file to reset the identity) as the Resource's
# user.id. The exporter/processor values bound the shutdown drain to ~1s
# against an unreachable collector: exporter.timeoutMillis is both the
# per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
@@ -200,6 +201,11 @@
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
# Human `/compact`: one useful reduction below the automatic threshold. Backend
# independent, so it follows whichever compaction service this leaf mounts.
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
@@ -244,6 +250,74 @@
config:
maxInlineBytes: 50000
# Durability checkpoints before each model request and top-level dispatch.
- id: session-checkpoint-policy
name: '@deepseek-ai/dsh-session-checkpoint-policy'
# Compacts oversized tool results before the broader conversation compactor
# runs, preserving the model-visible result within the configured budget.
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
config:
thresholdChars: 8192
headChars: 4096
tailChars: 1024
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Persisted same-session goals reach the model and the slash menu here; the
# domain, driver, and `/goal` command are above.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# Fresh-agent Ralph iteration over a build-time-fixed script.
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
subagentProvider: spawn
maxRounds: 64
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
config:
maxSearchResults: 100
searchTimeoutMs: 30000
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# Consecutive-repeat reminders on the tool chain.
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8]
argumentsPreviewChars: 500
# Every surface enables the stable web_search model surface. DeepSeek search
# resolves the same DEEPSEEK_API_KEY credential the Models page manages for
# chat, at each search; its Messages endpoint is separate from the
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
# disabled and no fetch provider is mounted: that provider defers SSRF
# protection and the model would choose the request target.
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-official
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
# ── rows every surface mounts, whose values each overlay states ──────────────
# The tool registry. Presentation mode is a surface choice, so each overlay

View File

@@ -0,0 +1,86 @@
# Opt-in two-tool profile over the shipped Web composition. The default native
# model surface is exactly persistent `bash` plus `str_replace_editor`; the
# Web host, browser shell, workspace, persistence, and permission stack remain.
# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
# always-registered exit_plan_mode tool even while the session is not planning.
- id: tool-bash
disabled: true
- id: tool-tasks
disabled: true
- id: tool-fs
disabled: true
- id: tool-fs-search
disabled: true
- id: tool-web
disabled: true
- id: tool-skill
disabled: true
- id: plan-mode
disabled: true
- id: tool-subagent
disabled: true
- id: tool-subagent-fork
disabled: true
- id: tool-workflow
disabled: true
- id: tool-todo
disabled: true
# These consumers are shared defaults on the ordinary shipped surfaces, but
# this opt-in profile keeps exactly its two named tools.
- id: tool-goal
disabled: true
- id: tool-ralph
disabled: true
- id: tool-session-query
disabled: true
- id: tool-str-replace-editor
disabled: true
# The matching browser controls must not offer host tools that this profile
# omits. ui-question's host half owns the ask_user_question registration.
- id: ui-plan
disabled: true
- id: ui-question
disabled: true
- insert:
- id: pty
name: '@deepseek-ai/dsh-pty'
# This backend consumes the existing Web sandbox and permission policy.
# It loads only on Linux/macOS; Windows and other platforms fail at boot.
# Its 300s send wait matches the persistent Bash command timeout instead of
# pty-local's 30s default. An open persistent shell fences permission-mode
# changes until it closes.
- id: pty-local
name: '@deepseek-ai/dsh-pty-local'
config:
timeoutMs: 300000
- id: persistent-bash
name: '@deepseek-ai/dsh-tool-bash-persistent'
config:
timeoutMs: 300000
# The editor consumes the Web fs-sandbox provider and therefore retains
# the selected session permission mode.
- id: str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000

View File

@@ -71,29 +71,17 @@
- id: agent-loop-invariant
name: '@deepseek-ai/dsh-agent-loop/invariant'
- id: session-checkpoint-policy
name: '@deepseek-ai/dsh-session-checkpoint-policy'
# The derived query index behind `/resume`. The launcher provides a unique
# process-local path because this SQLite backend has one writer owner; the
# project-local fallback applies when no launcher sets the typed slot.
- id: session-reference
name: '@deepseek-ai/dsh-session-reference'
# Compacts oversized tool results before the broader conversation compactor
# runs, preserving the model-visible result within the configured budget.
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
# Persisted same-session goals reach the model and the slash menu here; the
# domain, driver, and `/goal` command are in the base.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# A separate fixed consumer demonstrates fresh-agent Ralph iteration without
# changing the workflow tool or same-session goal behavior.
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
# Terminal-multiplexer context, mounted only where a terminal exists.
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 900000
# The keyboard-backed provider behind ask_user_question and the plan-mode
# review, and the front door it renders inside.

View File

@@ -97,7 +97,7 @@
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: './.storages'
root: !!js dshHomePath('storages')
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
@@ -113,9 +113,6 @@
writeEveryEvents: 200
writeIntervalMs: 5000
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Resolve bind host, SSH launch, and display once at boot, then mount the
# matching dual-face directory picker. Mount -native or -browse directly in
# an overlay to pin the interaction.

View File

@@ -47,6 +47,7 @@
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
@@ -68,9 +69,13 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
@@ -100,23 +105,30 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tmux-context": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",

View File

@@ -23,6 +23,22 @@ interface TuiInvocation {
resume?: string
}
/**
* Print the composed config tree and exit, without booting: `--dump-config`
* composes the shipped base, the surface overlay, and the `--config` or
* personal overlay — exactly the layers that surface would boot;
* `--dump-default-config` stops at the surface overlay (the shipped tree, no
* user layer).
*/
interface DumpConfigInvocation {
mode: 'dump-config'
surface: 'tui' | 'web'
/** Omit the `--config`/personal layer and print only the shipped composition. */
defaultOnly: boolean
/** The `--config` overlay to compose instead of the personal one. */
config?: string
}
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode: 'headless'
@@ -69,6 +85,7 @@ interface WebInvocation {
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation =
| TuiInvocation
| DumpConfigInvocation
| HeadlessInvocation
| MetaInvocation
| SkillSessionInvocation
@@ -82,6 +99,34 @@ interface WebOptions {
dev?: boolean
workspaceRoot?: string
trustedHost?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}
/**
* Resolve the two dump flags for one surface, or return `undefined` when
* neither was passed. Both flags together are contradictory (one includes the
* user layer, the other excludes it) and fail loud through `error`.
*/
function resolveDump(
surface: 'tui' | 'web',
options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
error: (message: string) => never,
): DumpConfigInvocation | undefined {
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
error('error: --dump-config and --dump-default-config are mutually exclusive')
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && options.config !== undefined) {
error('error: --dump-default-config prints the shipped tree and takes no --config')
}
return {
mode: 'dump-config',
surface,
defaultOnly,
...options.config !== undefined && { config: options.config },
}
}
/**
@@ -135,7 +180,26 @@ Examples:
.option('--resume <id>', 'continue a past session by id')
.option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
.option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
.action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => {
.option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit')
.action((options: {
config?: string
configReplace?: string
prompt?: string
resume?: string
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}) => {
const dump = resolveDump('tui', options, message => program.error(message))
if (dump !== undefined) {
// The dump prints composition; a boot-only flag alongside it would be
// silently ignored, so reject the mix loud.
if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) {
program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace')
}
resolved = dump
return
}
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
// run, and --config/--resume are TUI inputs that must not silently
@@ -168,10 +232,18 @@ Examples:
// a leaked config/prompt/resume option is a mistyped invocation that must fail
// loud rather than silently run and drop the input.
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>()
const parent = program.opts<{
config?: string
configReplace?: string
prompt?: string
resume?: string
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}>()
if (parent.config !== undefined || parent.configReplace !== undefined
|| parent.prompt !== undefined || parent.resume !== undefined) {
program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`)
|| parent.prompt !== undefined || parent.resume !== undefined
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`)
}
}
@@ -198,8 +270,15 @@ Examples:
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
.action((options: WebOptions) => {
rejectParentOptions('web')
const dump = resolveDump('web', options, message => program.error(message))
if (dump !== undefined) {
resolved = dump
return
}
resolved = resolveWeb(options)
})

View File

@@ -43,14 +43,19 @@ switch (invocation.mode) {
await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace)
break
}
case 'dump-config': {
const { runDumpConfig } = await import('./dump-config.ts')
runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config)
break
}
case 'meta': {
const { runMeta } = await import('./tui.ts')
await runMeta()
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
await runTui(undefined, undefined, SOURCE_ROOT)
break
}
case 'upgrade': {
const { runSkillSession } = await import('./tui.ts')
await runSkillSession(`dsh-${invocation.mode}`)
const { runTui } = await import('./tui.ts')
await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
break
}
default:

View File

@@ -0,0 +1,61 @@
/**
* `dsh --dump-config` / `dsh web --dump-config` — print the composed config
* tree without booting: the shipped base, the surface overlay, and (unless
* `--dump-default-config`) the `--config` or personal overlay, composed
* through the include's own patch algorithm so the printed tree is exactly
* what that surface would mount. `!!js` expressions print verbatim,
* unevaluated — the dump shows composition, not one process's environment.
* Launcher-provided boot-context values (session identity, CLI-flag patches)
* are per-invocation facts outside the config tree and do not appear.
* @module @deepseek-ai/dsh/dump-config
*/
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
loadOverlayPatches,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
renderConfigDump,
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const NAME = 'dsh'
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const SURFACE_OVERLAYS = {
tui: fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)),
web: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
} as const
/* v8 ignore start -- composition over the unit-tested renderConfigDump; the
built-bin e2e drives this path end to end */
/**
* Print one surface's composed config tree to stdout, with a comment
* separator naming the file each section of rows comes from (and the layers
* that patched it).
* @param surface - which surface overlay to compose over the shared base.
* @param defaultOnly - stop at the surface overlay (no `--config`/personal layer).
* @param config - the `--config` overlay path composed instead of the personal
* one, or `undefined` to use `$DSH_HOME/config.yaml`.
*/
export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void {
const overlay = SURFACE_OVERLAYS[surface]
const layers: ConfigDumpLayer[] = [
{ label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) },
]
if (!defaultOnly) {
if (config === undefined) {
const personal = loadPersonalPatches(NAME)
// The personal file may be absent; the shipped layers still print.
if (personal !== undefined) {
layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal })
}
} else {
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
}
}
process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers))
}
/* v8 ignore stop */

View File

@@ -8,12 +8,10 @@
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory.
* `dsh meta`
* ({@link runMeta}) is the one exception — it makes this harness checkout the
* workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh
* session whose first turn auto-invokes a bundled skill. After boot, the
* agent's system prompt is told the path to this harness checkout so it can
* find its own source.
* `dsh meta` is the one exception — it makes this harness checkout the
* workspace. `dsh upgrade` is a fresh session whose first turn auto-invokes a
* bundled skill. After boot, the agent's system prompt is told the path to this
* harness checkout so it can find its own source.
* @module @deepseek-ai/dsh/tui
*/
@@ -61,30 +59,11 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives.
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** The harness checkout used as the `dsh meta` workspace and source prompt path. */
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the CLI PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI with this harness checkout as the workspace
* (`dsh meta`), whatever directory it was launched from.
*/
export async function runMeta(): Promise<void> {
return runTui(undefined, undefined, SOURCE_ROOT)
}
/**
* Run the interactive TUI as a guided fresh session whose first turn invokes a
* bundled skill (`dsh upgrade` → `dsh-upgrade`).
* Always mints a fresh session in the invoking directory; the skill is seeded
* only on this first launch, so a later `--resume` of the session is an ordinary
* TUI session with no re-injection.
* @param skill - the bundled skill name to auto-invoke as the first turn.
*/
export async function runSkillSession(skill: string): Promise<void> {
return runTui(undefined, undefined, undefined, skill)
}
/**
* Run the interactive TUI from the invoking directory.
* @param config - an overlay patch list applied over the shared base and the
@@ -99,9 +78,8 @@ export async function runSkillSession(skill: string): Promise<void> {
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
* first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
* on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}.
* first turn, or `undefined`. Set only by `dsh upgrade` and ignored on a resume,
* so it never re-fires; reaches the app through {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
* to compose them; already parsed from `--config-replace`.

View File

@@ -45,6 +45,29 @@ describe('parseDshArgs', () => {
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
})
it('routes the dump flags per surface: composed with the user layer, or shipped only', () => {
expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false })
expect(parse(['--dump-config', '--config', 'c.yml']))
.toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' })
expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true })
expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false })
expect(parse(['web', '--dump-config', '--config', 'w.yml']))
.toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' })
expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true })
// The two dump flags contradict each other; boot-only flags alongside a
// dump would be silently ignored; the shipped tree takes no user overlay.
expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1)
expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1)
expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
// A leaked dump flag on a subcommand that has none is a mistyped invocation.
expect(exitCode(['meta', '--dump-config'])).toBe(1)
expect(exitCode(['upgrade', '--dump-config'])).toBe(1)
})
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
// Empty resume/prompt would be swallowed downstream; --prompt mixed with
// TUI inputs must not lose them. (Bad host/port are gated by the webserver

View File

@@ -1,8 +1,9 @@
import { existsSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
@@ -22,13 +23,20 @@ import { describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
const result = await execa(process.execPath, [dshBin], {
/**
* Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
* + exit code. `env` isolates the Harness home for surfaces that read it.
*/
async function runBuiltBin(
args: readonly string[] = [],
env: Record<string, string> = {},
): Promise<{ stdout: string; code: number; stderr: string }> {
const result = await execa(process.execPath, [dshBin, ...args], {
input: '',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
env,
})
if (result.timedOut) {
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
@@ -45,4 +53,61 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
describe('dsh --dump-config', () => {
let home: string
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
it('prints the shipped TUI composition without booting or needing a TTY', async () => {
const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
expect(code).toBe(0)
expect(stderr).toBe('')
// Base rows composed with the TUI overlay's surface values, `!!js`
// expressions verbatim (unevaluated), and TUI-only inserted rows present.
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
expect(stdout).toContain('model: deepseek-v4-pro')
expect(stdout).toContain('cwd: !!js process.cwd()')
expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
// Provenance comment separators name each section's source file.
expect(stdout).toContain('# == base.cordis.yml')
expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml')
expect(stdout).toContain('# == tui.cordis.yml')
}, 30_000)
it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => {
writeFileSync(join(home, 'config.yaml'), [
'- id: agent-loop',
' config:',
' agents:',
' - id: main',
' provider: custom-provider',
' model: custom-model',
'- id: only-on-web',
' config:',
' value: 1',
'',
].join('\n'))
const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
expect(code).toBe(0)
expect(stdout).toContain('provider: custom-provider')
expect(stdout).not.toContain('model: deepseek-v4-pro')
// The personal layer appears in the patched row's provenance and the
// skipped-patch warning carries its label.
expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`)
expect(stderr).toContain('patch: entry "only-on-web" not found')
// The shipped view ignores the personal overlay entirely.
const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
expect(shipped.stdout).not.toContain('custom-provider')
expect(shipped.stdout).toContain('model: deepseek-v4-pro')
}, 30_000)
it('composes the web overlay for `dsh web --dump-config`', async () => {
const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
expect(code).toBe(0)
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'")
}, 30_000)
})
})

View File

@@ -0,0 +1,51 @@
import type { Context } from 'cordis'
import type {
GenerateOptions,
LlmModelInfo,
LlmResolvedModelInfo,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
/** Terminal marker the preset smoke waits for before it asks the TUI to exit. */
export const COMPOSITION_REPLY_TEXT = 'Shipped composition acknowledged.'
// Provider id and model the keyless tail routes `main` to; that overlay is the
// only caller, so the pair lives here as plain constants.
const COMPOSITION_PROVIDER = 'composition-keyless'
const COMPOSITION_MODEL = 'composition-keyless-model'
/**
* Network-free adapter for the shipped-composition smoke. It answers every
* request — tool-ful agent turns and the tool-less auxiliary calls alike — with
* one fixed text and never calls a tool, because the assertion under test is the
* assembled tool catalog the loop logs, not any tool's behavior.
*/
class CompositionEchoAdapter extends LlmAdapter {
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve([{ provider, id: COMPOSITION_MODEL, name: 'Preset Keyless' }])
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: 'Preset Keyless', context: { contextWindow: 128_000 } })
}
override async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
yield { type: 'block-start', index: 0, blockType: 'text' }
for (const char of COMPOSITION_REPLY_TEXT) yield { type: 'text-delta', index: 0, text: char }
yield { type: 'block-end', index: 0, block: { type: 'text', text: COMPOSITION_REPLY_TEXT } }
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: COMPOSITION_REPLY_TEXT.length } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'composition-echo-llm'
export const inject = ['llm']
/**
* Register the network-free adapter the shipped-composition smoke routes through.
* @param ctx - the loader-mounted plugin context.
*/
export function apply(ctx: Context): void {
ctx.llm.registerAdapter([COMPOSITION_PROVIDER], new CompositionEchoAdapter())
}

View File

@@ -0,0 +1,52 @@
# Keyless tail for the shipped-composition smoke, applied as `--config` so the
# launcher boots `base.cordis.yml` + `tui.cordis.yml` and then this file.
#
# Everything below is test isolation, never composition under test: the model is
# replaced so no request leaves the process, the settle marker gates the smoke's
# first prompt, and the session artifacts move into the smoke's temporary
# workspace so the log inspection can read them.
# A patch's `name` is an assertion rather than a replacement, so the base
# adapter row is disabled and the scripted one inserted. Relative specifiers
# resolve against the INCLUDED file's directory (apps/cli/config), not this
# file's, because the include moves baseUrl there.
- id: llm-deepseek
disabled: true
- insert:
- id: composition-echo-llm
name: '../tests/fixtures/composition-echo-llm.ts'
- id: composition-settled
name: '../tests/fixtures/composition-settled.ts'
- id: agent-loop
config:
agents:
- id: main
provider: composition-keyless
model: composition-keyless-model
cwd: !!js process.cwd()
- id: session-persistence-jsonl
config:
root: './.sessions'
compression: none
- id: session-query-sqlite
config:
path: './.sessions/session-query.db'
# The title call is a second, tool-less request that would race the log
# inspection for no coverage: the catalog under test rides the agent turn.
- id: session-title-llm
disabled: true
- id: tui
config:
sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'
welcome: 'composition smoke ready.'
showReasoning: true
# HMR watches the repository; a PTY subprocess test must not start a watcher.
- id: hmr
disabled: true

View File

@@ -0,0 +1,24 @@
import type { Context } from 'cordis'
/**
* Marker the shipped-composition smoke gates its first prompt on. The TUI renders as soon as
* its own fiber starts, so a prompt typed at the banner can reach the loop while
* later rows — tool plugins, persistence — are still activating, and would
* assemble a partial catalog. Waiting for this line makes the turn observe the
* settled tree.
*/
export const COMPOSITION_SETTLED_MARKER = 'COMPOSITION_TREE_SETTLED'
export const name = 'composition-settled'
/**
* Announce settled Loader activation on the terminal byte stream, after every
* entry in the booted tree has started. The write is detached: awaiting the
* Loader from inside an entry would wait on this entry's own activation.
* @param ctx - the loader-mounted plugin context.
*/
export function apply(ctx: Context): void {
void ctx.loader.await().then(() => {
process.stdout.write(`\n${COMPOSITION_SETTLED_MARKER}\n`)
})
}

View File

@@ -0,0 +1,8 @@
# Minimal keyless composition for loading example MCP overlays against the
# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins
# keep this unit test independent of prebuilt workspace artifacts.
- id: system-prompt
name: cordis:memory-test-system-prompt
- id: tools
name: cordis:memory-test-tools

View File

@@ -0,0 +1,132 @@
/**
* The third-party memory examples stay config-only. This suite parses every
* checked-in overlay, verifies its pin/transport/secret boundary, then replaces
* only the upstream endpoint with the package-owned keyless MCP fixture and
* proves the real Cordis Loader discovers a tool through the generic bridge.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts'
interface ExampleContract {
file: string
id: string
serverName: string
transport: 'stdio' | 'streamable-http'
pin: string
}
interface InsertedRow {
id?: string
name?: string
config?: Record<string, unknown>
}
const root = resolve(import.meta.dirname, '../../..')
const exampleDir = resolve(root, 'examples/mcp-memory')
const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml')
const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts')
const examples: ExampleContract[] = [
{
file: 'memorix.cordis.yml',
id: 'memory-memorix',
serverName: 'memorix',
transport: 'stdio',
pin: '1.3.0',
},
{
file: 'mcp-reference-memory.cordis.yml',
id: 'memory-mcp-reference',
serverName: 'reference_memory',
transport: 'stdio',
pin: '2026.7.4',
},
{
file: 'engram.cordis.yml',
id: 'memory-engram',
serverName: 'engram',
transport: 'stdio',
pin: '1.20.0',
},
]
const liveContexts = new Set<Context>()
afterEach(async () => {
await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose()))
liveContexts.clear()
})
function insertedRow(patches: PatchOptions[]): InsertedRow {
expect(patches).toHaveLength(1)
const insert = patches[0]?.insert
expect(insert).toHaveLength(1)
return insert?.[0] as InsertedRow
}
async function waitForTool(ctx: Context, name: string): Promise<void> {
const deadline = Date.now() + 10_000
while (!ctx.tools.schemas().some(schema => schema.name === name)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`)
await new Promise(resolveWait => setTimeout(resolveWait, 25))
}
}
describe('third-party memory MCP example overlays', () => {
it.each(examples)('parses $file with the documented generic boundary', (contract) => {
const file = resolve(exampleDir, contract.file)
const source = readFileSync(file, 'utf8')
const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))
expect(row.id).toBe(contract.id)
expect(row.name).toBe('@deepseek-ai/dsh-mcp-client')
expect(row.config?.serverName).toBe(contract.serverName)
expect(row.config?.transport).toBe(contract.transport)
expect(source.split('\n', 1)[0]).toContain(contract.pin)
expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/)
expect(source).not.toContain('DEEPSEEK_API_KEY')
})
it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => {
const patches = loadOverlayPatches(
'memory-mcp-config-test',
resolve(exampleDir, contract.file),
)
// The static config gate verifies the checked-in bare package specifier.
// The unit test maps it to the source module so a clean checkout needs no
// prebuilt `lib/` artifacts before proving the Loader/MCP behavior.
insertedRow(patches).name = 'cordis:memory-test-mcp-client'
const fixturePatch: PatchOptions = {
id: contract.id,
config: {
serverName: contract.serverName,
transport: 'stdio',
command: process.execPath,
args: [fixtureServer],
env: {},
cwd: root,
toolCallTimeoutMs: 5_000,
},
}
const ctx = await boot(
'memory-mcp-config-test',
baseConfig,
[...patches, fixturePatch],
(ctx) => {
liveContexts.add(ctx)
ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt
ctx.loader.builtins['memory-test-tools'] = ToolRegistry
ctx.loader.builtins['memory-test-mcp-client'] = McpClient
},
)
await waitForTool(ctx, `mcp__${contract.serverName}__greet`)
}, 15_000)
})

View File

@@ -0,0 +1,124 @@
import { readdir, readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { COMPOSITION_REPLY_TEXT } from './fixtures/composition-echo-llm.ts'
import { COMPOSITION_SETTLED_MARKER } from './fixtures/composition-settled.ts'
import { runTuiPtySmoke } from './pty-harness.ts'
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// An overlay over the shipped tree, so the catalog under test is the one
// `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model
// and redirects session artifacts.
const keylessTail = fileURLToPath(new URL('./fixtures/composition-keyless-tail.cordis.yml', import.meta.url))
/**
* The catalog the shipped `dsh` TUI puts in front of the model, as the loop
* logged it, minus the ripgrep-dependent pair below.
* The absences are the composition's security decisions, not incidental gaps:
* the `cordis_*` toolset executes model-written JavaScript that no sandbox row
* confines, `web_fetch` chooses its own request target, and `mcp_*` servers
* spawn outside `ctx.bash`. The composition Agent Note owns the rationale and
* its sources.
*/
const EXPECTED_TUI_TOOLS = [
'ask_user_question',
'bash',
'create_goal',
'edit',
'exit_plan_mode',
'get_goal',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'skill',
'str_replace_editor',
'subagent',
'subagent_fork',
'task_kill',
'task_list',
'task_output',
'todo_write',
'update_goal',
'web_search',
'workflow',
'write',
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
/** The assembled request header the smoke asserts on. */
interface LoggedHeader {
/** Assembled tool names, sorted. */
names: string[]
/** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */
bashArguments: Record<string, unknown>
}
/**
* Read the request header the loop assembled for its first request from the
* session log the smoke's workspace persisted — the model-visible composition
* itself, not a registry projection taken beside it.
* @param cwd - the smoke's temporary workspace.
* @returns the assembled catalog, system prompt, and `bash` argument shape.
*/
async function loggedHeader(cwd: string): Promise<LoggedHeader> {
const sessionsDir = join(cwd, '.sessions')
const entries = await readdir(sessionsDir, { recursive: true })
// A single keyless run writes one session log.
const logRelPath = entries.find(name => name.endsWith('.jsonl'))
if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
for (const line of lines) {
const event = JSON.parse(line) as SessionEvent
if (event.type !== 'request/header') continue
const tools = event.data.header.tools ?? []
const bash = tools.find(schema => schema.name === 'bash')
return {
names: tools.map(schema => schema.name).sort(),
bashArguments: (bash?.parameters as { properties?: Record<string, unknown> } | undefined)?.properties ?? {},
}
}
throw new Error(`session log ${logRelPath} has no request/header event`)
}
describe('shipped dsh composition (real Loader tree in a PTY)', () => {
it('assembles exactly the shipped TUI catalog', async () => {
let observed: LoggedHeader | undefined
const output = await runTuiPtySmoke({
label: 'dsh shipped composition',
tempDirPrefix: 'dsh-shipped-tui-',
binScript: dshBinScript,
tsconfigPath,
configPath: keylessTail,
env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' },
// Artifact CI builds and smokes concurrently on a contended runner.
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
actions: [
{ waitFor: COMPOSITION_SETTLED_MARKER, send: 'Describe the shipped composition.\r' },
{ waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' },
],
inspect: async (cwd) => { observed = await loggedHeader(cwd) },
})
expect(output).toContain(COMPOSITION_REPLY_TEXT)
expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name)))
// The TUI mounts the unrestricted local executors, so `tool-bash` emits no
// escalation pair. Pinning its absence keeps a later sandbox change from
// arriving here unannounced.
expect(Object.keys(observed?.bashArguments ?? {})).not.toContain('sandbox_permissions')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,128 @@
terminal 100x36 buffer=normal length=66 base=30 viewport=30
lifecycle started=1 stopped=0 progress=inactive
title "Reply with exactly the word: — DSH TUI snapshot"
cursor hidden column=7 viewportRow=35 bufferRow=65
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " Reply with exactly the word:"
style 1-28 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "Context · snapshot-seed"
style 0-22 dim
5| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
6| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
7| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
8| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
9| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
10| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
11| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
12| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
13| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
14| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
15| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
16| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
17| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
18| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-99 dim
19| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
style 0-94 dim
20| <blank>
21| "You "
style 0-2 fg=bright-magenta bold underline
22| "Reply with exactly the word: ONE. No tools. "
23| <blank>
24| "Assistant "
style 0-8 fg=bright-magenta bold underline
25| "Reasoning "
style 0-8 dim italic
26| "The user wants me to reply with exactly the word \"ONE\" and use no tools. "
style 0-71 dim italic
27| "ONE "
28| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
29| <blank>
30| "Keyboard shortcuts "
style 0-17 fg=bright-magenta bold
31| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 0-60 dim
32| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L "
style 0-99 dim
33| "redraw "
style 0-5 dim
34| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 0-72 dim
35| " "
36| "/clear — Clear the transcript view (session history is unchanged) "
style 0-64 dim
37| "/compact — Compact older conversation history "
style 0-44 dim
38| "/exit — Exit after the active turn reaches idle "
style 0-46 dim
39| "/help — Show keyboard shortcuts and commands "
style 0-43 dim
40| "/model [[provider/]model] — Show or switch this session's model "
style 0-62 dim
41| "/palette — Show every color and attribute role this terminal renders "
style 0-67 dim
42| "/quit — Exit after the active turn reaches idle "
style 0-46 dim
43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 0-87 dim
44| "/resume — List this workspace's resumable sessions "
style 0-49 dim
45| "/status — Show session diagnostics, system prompt, and registered tools "
style 0-70 dim
46| "/skill:<name> [instructions] — load a skill into the conversation "
style 0-64 dim
47| <blank>
48| "Context · snapshot-injector"
style 0-26 dim
49| "Injected while compaction was running. "
style 0-37 dim
50| <blank>
51| "… earlier context was compacted … "
style 0-32 dim
52| <blank>
53| "You "
style 0-2 fg=bright-magenta bold underline
54| "Reply with exactly the word: TWO. No tools. "
55| <blank>
56| "Compacted 2 history items (~387 tokens). "
style 0-39 dim
57| <blank>
58| "Assistant "
style 0-8 fg=bright-magenta bold underline
59| "Reasoning "
style 0-8 dim italic
60| "The user wants me to reply with exactly the word \"TWO\" and no tools. "
style 0-67 dim italic
61| "TWO "
62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
63| <blank>
64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont"
style 0-49 fg=bright-magenta bold
style 52-68 dim
style 71-90 dim
style 93-99 dim
65| " dsh ◍ "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -11,8 +11,12 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
import CommandService from '@deepseek-ai/dsh-commands'
import * as CommandCompact from '@deepseek-ai/dsh-command-compact'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
@@ -45,6 +49,8 @@ type ScenarioInteraction = 'skill-invocation-policy'
interface Scenario {
name: string
/** Replay fixture owned by an earlier scenario, for a derived presentation case. */
fixture?: string
composition: Composition
expectedTools: string[]
expectedEventCounts?: Record<string, number>
@@ -68,6 +74,13 @@ interface Scenario {
spillMaxInlineBytes?: number
/** Run scenario-specific terminal input instead of replaying recorded user prompts. */
interaction?: ScenarioInteraction
/**
* Mount a deterministic compaction backend plus `/compact`, then run the
* human command with a held summary while a prompt and injected context
* arrive. Proves queued input waits for the standalone bracket's durability
* checkpoint instead of racing the replacement.
*/
manualCompact?: boolean
}
const SCENARIOS: Scenario[] = [
@@ -80,6 +93,14 @@ const SCENARIOS: Scenario[] = [
leavePlanModeAfterFirstTurn: true,
recorded: true,
},
{
name: 'queued-manual-compact',
fixture: 'multi-turn-conversation',
composition: 'native',
expectedTools: [],
recorded: false,
manualCompact: true,
},
{
name: 'todo-plan',
composition: 'native',
@@ -149,6 +170,44 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
const observedScenarios = new Set<string>()
const workerState = Reflect.get(globalThis, '__vitest_worker__') as
| { readonly config?: { readonly testNamePattern?: RegExp } }
| undefined
// Worker argv omits the parent CLI's `-t`; the serialized runner config is the
// authoritative distinction between a focused replay and the full suite.
const TEST_NAME_FILTERED = workerState?.config?.testNamePattern !== undefined
/**
* Deterministic keyless summary that pauses so the scenario can submit a real
* prompt and inject context while manual compaction holds turn admission.
*/
class DeferredSnapshotCompactService extends BasicCompactService {
readonly summaryStarted = Promise.withResolvers<undefined>()
readonly releaseSummary = Promise.withResolvers<undefined>()
override async summarize(
_input: SummarizationInput,
_agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: [{ type: 'text'; text: string }]; provider: string; model: string }> {
this.summaryStarted.resolve(undefined)
await this.releaseSummary.promise
signal?.throwIfAborted()
return {
summary: [{ type: 'text', text: 'Keyless manual compaction checkpoint.' }],
provider: 'snapshot',
model: 'snapshot-compactor',
}
}
}
/** Seed between-turn model-visible history without inventing a loop execution. */
function seedCompactableHistory(agent: Agent): void {
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'Older snapshot context. '.repeat(60) }],
source: { kind: 'plugin', plugin: 'snapshot-seed' },
}))
}
function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
const rel = relative(cwd, displayPath)
@@ -161,10 +220,15 @@ function scenarioDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.name)
}
/** Directory owning the replay fixture: the scenario's own, or the one it derives from. */
function fixtureDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.fixture ?? scenario.name)
}
function childFixturePaths(scenario: Scenario): string[] {
return Array.from(
{ length: scenario.childSessions ?? 0 },
(_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`),
(_, index) => join(fixtureDir(scenario), `session.${index + 1}.jsonl`),
)
}
@@ -206,6 +270,24 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms')
}
/** Bound deterministic in-process coordination waits with actionable state. */
async function snapshotDeadline<T>(
operation: Promise<T>,
detail: () => string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => { reject(new Error(detail())) }, 5_000)
}),
])
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
async function mountScenarioContext(
scenario: Scenario,
cwd: string,
@@ -232,6 +314,9 @@ async function mountScenarioContext(
skills: { local: { agentsHome: join(cwd, '.agents') } },
})
await ctx.plugin(TokenMeterService)
if (scenario.manualCompact === true) {
await ctx.plugin(DeferredSnapshotCompactService, { auto: false })
}
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
@@ -249,6 +334,7 @@ async function mountScenarioContext(
await ctx.plugin(ToolWorkflow)
await ctx.plugin(ToolRalph)
await ctx.plugin(CommandService)
if (scenario.manualCompact === true) await ctx.plugin(CommandCompact)
if (scenario.enterPlanMode === true) {
await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
}
@@ -276,9 +362,9 @@ interface ScenarioResult {
}
async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime())
const dir = scenarioDir(scenario)
const fixtureFile = join(dir, 'session.jsonl')
const snapshotTime = new Date(2026, 6, 21, 12, 0, 0).getTime()
const clock = vi.spyOn(Date, 'now').mockReturnValue(snapshotTime)
const fixtureFile = join(fixtureDir(scenario), 'session.jsonl')
const childFiles = childFixturePaths(scenario)
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
if (scenario.interaction === undefined) {
@@ -292,7 +378,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const terminal = new HeadlessTerminal(100, 36)
try {
if (scenario.seedWorkspace === true) {
const source = join(scenarioDir(scenario), 'workspace')
const source = join(fixtureDir(scenario), 'workspace')
await cp(source, cwd, { recursive: true })
}
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
@@ -308,6 +394,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
const agent: Agent = handle.agent
if (scenario.manualCompact === true) seedCompactableHistory(agent)
controller = createTuiChat(ctx, {
sessionId: 'main-session',
theme: { color: true },
@@ -380,6 +467,14 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
}
let remainingPrompts = prompts
let queuedPrompt: string | undefined
let manualOrder: string[] | undefined
let manualCommandId: string | undefined
if (scenario.manualCompact === true) {
expect(prompts.length, 'queued manual compaction needs a second replayed prompt').toBeGreaterThanOrEqual(2)
queuedPrompt = prompts.at(-1)
remainingPrompts = prompts.slice(0, -1)
}
if (scenario.enterPlanMode === true) {
const firstPrompt = prompts[0]!
terminal.send(`/plan ${firstPrompt}`)
@@ -396,12 +491,93 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
}
for (const prompt of remainingPrompts) {
const admitted = agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind === 'user').length
terminal.send(prompt)
terminal.send('\r')
await terminal.flush()
await expect.poll(() => agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind === 'user').length).toBe(admitted + 1)
await agent.whenIdle()
await settleTerminal(terminal)
}
if (scenario.manualCompact === true && queuedPrompt !== undefined) {
terminal.send('/help')
terminal.send('\r')
await settleTerminal(terminal)
expect(await terminal.snapshot({ includeScrollback: true }))
.toContain('/compact — Compact older conversation history')
const compact = ctx.compact as DeferredSnapshotCompactService
const inbox: string[] = []
manualOrder = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`)
})
ctx.on('agent/inbox/dequeue', (subject, message) => {
if (subject === agent) inbox.push(`dequeue:${message.id}`)
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'command/run' && event.data.name === 'compact') {
manualCommandId = event.data.commandId
manualOrder?.push('command/run')
}
if (event.type === 'command/done' && event.data.commandId === manualCommandId) {
manualOrder?.push('command/done')
}
if (event.type.startsWith('compact/')) manualOrder?.push(event.type)
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'compact') manualOrder?.push('checkpoint')
if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`)
})
ctx.on('session/flush', (session) => {
if (session === agent.session) manualOrder?.push('flush')
})
terminal.send('/compact')
terminal.send('\r')
await terminal.flush()
await snapshotDeadline(compact.summaryStarted.promise, () =>
`manual summary did not start; status=${agent.status}; tail=${
agent.session.events.slice(-8).map(event => event.type).join(',')
}`)
clock.mockReturnValue(snapshotTime + 1_000)
await settleTerminal(terminal)
await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙')
await expect.poll(() => terminal.snapshot()).toContain('Context being compacted 1.0s')
const liveCompaction = await terminal.snapshot()
expect(liveCompaction.indexOf('Context being compacted 1.0s')).toBeLessThan(liveCompaction.indexOf('dsh ⊙'))
clock.mockReturnValue(snapshotTime)
// Real keystrokes: the prompt keeps its ordinary queue identity while
// admission is reserved, and an injection appends immediately.
terminal.send(queuedPrompt)
terminal.send('\r')
await terminal.flush()
await expect.poll(() => inbox.length).toBe(1)
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'Injected while compaction was running.' }],
source: { kind: 'plugin', plugin: 'snapshot-injector' },
}))
expect(inbox[0]).toMatch(/^enqueue:queued:/u)
expect(agent.status).toBe('idle')
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text === queuedPrompt))).toBe(false)
const idle = agent.whenIdle()
compact.releaseSummary.resolve(undefined)
await snapshotDeadline(idle, () =>
`manual compaction did not reach idle; status=${agent.status}; order=${manualOrder?.join(',') ?? ''}; tail=${
agent.session.events.slice(-12).map(event => event.type).join(',')
}`)
await settleTerminal(terminal)
expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`])
}
const events: SessionEvent[] = [...agent.session.events]
const firstHeader = events.find(event => event.type === 'request/header')
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
@@ -437,6 +613,87 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
.toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
}
if (scenario.manualCompact === true) {
const compactStart = events.find(event => event.type === 'compact/start')
const compactSummary = events.find(event => event.type === 'compact/summary')
const compactCheckpoint = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'compact')
const injectedEvent = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'snapshot-injector')
const compactEnd = events.find(event => event.type === 'compact/end')
expect(compactStart?.data.turn).toBeNull()
expect(compactEnd?.data.turn).toBeNull()
expect(events.filter(event => event.type === 'compact/summary')).toHaveLength(1)
if (compactStart === undefined || compactSummary === undefined
|| compactCheckpoint === undefined || injectedEvent === undefined
|| compactEnd === undefined) {
throw new Error('manual compaction snapshot is missing its durable marker, summary, checkpoint, or injection')
}
// The markers are time points, not an exclusive container: unrelated
// idle injection is allowed between them while the selected span stays stable.
expect(compactStart.seq).toBeLessThan(injectedEvent.seq)
expect(injectedEvent.seq).toBeLessThan(compactSummary.seq)
expect(compactSummary.seq).toBeLessThan(compactCheckpoint.seq)
expect(compactCheckpoint.seq).toBeLessThan(compactEnd.seq)
const manualTimeline = manualOrder ?? []
const commandRunIndex = manualTimeline.indexOf('command/run')
const compactStartIndex = manualTimeline.indexOf('compact/start')
const compactEndIndex = manualTimeline.indexOf('compact/end')
const firstFlushIndex = manualTimeline.indexOf('flush')
const queuedTurnIndex = manualTimeline.indexOf('turn/start:message')
const commandDoneIndex = manualTimeline.indexOf('command/done')
expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1)
expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1)
expect(compactStartIndex).toBeGreaterThan(commandRunIndex)
expect(compactEndIndex).toBeGreaterThan(compactStartIndex)
expect(firstFlushIndex).toBeGreaterThan(compactEndIndex)
expect(queuedTurnIndex).toBeGreaterThan(firstFlushIndex)
expect(commandDoneIndex).toBeGreaterThan(firstFlushIndex)
const commandRun = events.find(event => event.type === 'command/run'
&& event.data.name === 'compact')
const commandRunId = commandRun?.type === 'command/run'
? commandRun.data.commandId
: undefined
const commandDone = events.find(event => event.type === 'command/done'
&& event.data.commandId === commandRunId)
expect(commandRun?.type === 'command/run' && commandRun.data).toEqual({
commandId: commandRunId,
name: 'compact',
args: '',
source: { kind: 'user' },
})
expect(commandDone?.type === 'command/done' && commandDone.data).toEqual({
commandId: commandRunId,
kind: 'success',
text: 'Compacted 2 history items (~387 tokens).',
})
expect(commandRun !== undefined && commandRun.seq < compactStart.seq).toBe(true)
expect(commandDone !== undefined && commandDone.seq > compactEnd.seq).toBe(true)
expect(agent.session.surface.nodes).not.toContain(commandRun?.seq)
expect(agent.session.surface.nodes).not.toContain(commandDone?.seq)
// The command line itself never becomes a prompt.
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text.trim() === '/compact'))).toBe(false)
const derived = agent.session.deriveMessages().map(message => message.content
.map(block => block.type === 'text' ? block.text : '')
.join(''))
const checkpoint = derived.findIndex(text => text.includes('Keyless manual compaction checkpoint.'))
const injected = derived.findIndex(text => text.includes('Injected while compaction was running.'))
const queued = derived.findIndex(text => text === queuedPrompt)
expect(checkpoint).toBe(0)
expect(injected).toBeGreaterThan(checkpoint)
expect(queued).toBeGreaterThan(injected)
expect(derived).not.toContain('/compact')
expect(derived).not.toContain('Compacted 2 history items (~387 tokens).')
expect(derived.filter(text => text.includes('Injected while compaction was running.'))).toHaveLength(1)
expect(compactSummary.data.shadowedSeqs).not.toContain(injectedEvent.seq)
const queuedTurn = events.findLast(event => event.type === 'turn/start')
expect(queuedTurn !== undefined && compactEnd.seq < queuedTurn.seq).toBe(true)
}
if (scenario.spillMaxInlineBytes !== undefined) {
// The REAL pipeline ran (tools execute on replay too): the durable
// dispatch copy is bounded to a preview + locator under the run cwd,
@@ -514,7 +771,23 @@ describe('TUI recorded-session terminal snapshots', () => {
})
afterAll(async () => {
expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
const scenarioNames = SCENARIOS.map(scenario => scenario.name).sort()
const observedNames = [...observedScenarios].sort()
if (TEST_NAME_FILTERED) {
expect(observedNames).not.toHaveLength(0)
expect(scenarioNames).toEqual(expect.arrayContaining(observedNames))
} else {
expect(observedNames).toEqual(scenarioNames)
}
for (const [index, scenario] of SCENARIOS.entries()) {
if (scenario.fixture === undefined) continue
const sourceIndex = SCENARIOS.findIndex(candidate => candidate.name === scenario.fixture)
expect(sourceIndex, `${scenario.name} fixture source ${scenario.fixture} must exist`).toBeGreaterThanOrEqual(0)
expect(sourceIndex, `${scenario.name} fixture source must precede it`).toBeLessThan(index)
const source = SCENARIOS[sourceIndex]
expect(source?.fixture, `${scenario.name} fixture source must own its replay files`).toBeUndefined()
expect(source?.recorded, `${scenario.name} fixture source must be recordable`).toBe(true)
}
const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true }))
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
@@ -522,14 +795,14 @@ afterAll(async () => {
expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
for (const scenario of SCENARIOS) {
const expected = [
'session.jsonl',
...scenario.fixture === undefined ? ['session.jsonl'] : [],
'terminal.expected.txt',
...scenario.seedWorkspace === true ? ['workspace'] : [],
...scenario.seedWorkspace === true && scenario.fixture === undefined ? ['workspace'] : [],
...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`),
].sort()
expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected)
for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) {
const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8')
const content = await readFile(join(fixtureDir(scenario), fixture), 'utf8')
expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content)
}
}

View File

@@ -2,6 +2,7 @@
// 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'
@@ -11,7 +12,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
/**
* connectFreshWorkspace twin over the product default Chinese locale (the
@@ -19,13 +20,16 @@ import { saveFailureShot } from './support.ts'
* boots; this scenario deliberately keeps zh, so the localized picker
* copy is the anchor set).
*/
async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise<void> {
async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
await page.getByRole('menuitem', { name: '新建工作区' }).click()
const dialog = page.getByRole('dialog', { name: '新建工作区' })
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('新工作区名称').fill(name)
await dialog.getByRole('button', { name: '创建工作区' }).click()
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 })
}
@@ -49,11 +53,11 @@ describe('web e2e: Full access confirmation', () => {
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
// Keep the product default Chinese locale: the golden pins the actual
// registered dictionary rather than a test-local translation callback.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspaceZh(page)
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -61,7 +61,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -112,11 +112,19 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
}, { timeout: 10_000 })
// The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
// The write turn's `hello fixture\n` proves the terminator rule end to end: a
// trailing newline terminates its line, so the footer reads `+1` (not a
// phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
// it is absent from textContent — assert on the line body and the footer.
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/
// write row first. The write turn's `hello fixture\n` proves the terminator
// rule end to end: a trailing newline terminates its line, so the footer reads
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
// ::before, so it is absent from textContent — assert on the line body and the
// footer.
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
expect(mutationRows.length).toBeGreaterThan(0)
for (const row of mutationRows) {
const toggle = row.querySelector('[data-expandable]')
if (toggle !== null) act(() => { fireEvent.click(toggle) })
}
const diffCards = [...document.querySelectorAll('[data-diff]')]
expect(diffCards.length).toBeGreaterThan(0)
const footers = diffCards.map(card => card.textContent ?? '')
@@ -125,13 +133,20 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The web render intent reaches the assembled boot graph: the fixture's
// web_search / web_fetch turns render their keyed WebRow cards, proving the
// registration, wire projection, and card rendering survive the real bundle
// path (not just the per-package src benches). The selector pins the KEYED
// WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
// WebBlock draws — the generic fallback renders the same WebBlock, so a silent
// keyed-registration failure would still satisfy a bare `[data-web]` check.
// path (not just the per-package src benches). WebRow composes ToolRow, so the
// card is collapsed behind the row; the keyed row is pinned by its `data-tool`
// (ToolRow sets it from the wire tool name).
const webSearchRow = await waitFor(() => {
const row = document.querySelector('[data-tool="web_search"]')
expect(row).not.toBeNull()
expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
return row!
}, { timeout: 10_000 })
// Expand the web_search row to prove its WebBlock card renders end to end.
const webToggle = webSearchRow.querySelector('[data-expandable]')
if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
await waitFor(() => {
expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
}, { timeout: 10_000 })
// Every bundle injected its plugin-owned style tag (the loader's CSS path).

View File

@@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -0,0 +1,397 @@
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
// GLYPHS, not just its caret.
//
// The composer paints its text in two stacked layers (see
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
// `<textarea>` carries the value, the selection and the caret but renders its
// own glyphs `color: transparent`, and every visible character is painted by the
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint. The backdrop is `position: absolute;
// inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
// browser links its scroll offset to the textarea's.
//
// So past the cap the textarea scrolled and the words did not: the caret walked
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
// backdrop on every textarea `scroll`, which is the one event every way of
// moving the box ends in.
//
// Mirroring an offset is only correct while both layers can reach it, so the
// geometry underneath is asserted here alongside the visible outcome: the
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
// caret after a final newline; `pre-wrap` collapses one), and one wrap width
// across all three layers (only the textarea scrolls, so only it can lose
// width to a scrollbar that consumes layout space). Either breaks the extent
// equality, and an unreachable offset clamps the glyphs below the caret.
//
// Only a real engine can show this. Scrolling is layout: jsdom reports
// `scrollHeight === clientHeight` for every element and never scrolls one, so
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx has
// to stub both offsets and can only prove the mirroring code path runs. What is
// asserted here instead is the user-visible fact that path exists for — after
// scrolling to the end of a long draft, the LAST line is the one on screen —
// measured with a DOM Range over the backdrop's own text.
//
// Zero model calls: a fresh workspace's blank session already carries a live
// composer, and the scenario only types into it. 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 {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
/**
* Committed golden of the composer's two-layer scroll geometry. The change
* alters no DOM and no accessible name, so the aria goldens the other scenarios
* commit are byte-identical with and without it; this records the relations
* instead, which makes a shift in the cap or in the layer coupling a reviewable
* diff rather than an assertion someone has to reconstruct.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Marks the first and last line so a Range can find them in the backdrop's text. */
const FIRST_MARKER = 'FIRST-LINE-MARKER'
const LAST_MARKER = 'LAST-LINE-MARKER'
/** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
const DRAFT_LINES = 40
const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
if (index === 0) return FIRST_MARKER
if (index === DRAFT_LINES - 1) return LAST_MARKER
return `draft line ${String(index + 1).padStart(2, '0')}`
}).join('\n')
/**
* A draft ending in a newline: the shape whose layer extents diverge without
* the backdrop's trailing-line sentinel. A textarea reserves a line box for the
* caret after a final newline; `white-space: pre-wrap` collapses a text node's
* trailing newline and generates none, so the backdrop would come out exactly
* one line shorter and the mirrored offset would clamp a line above the caret.
*/
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
/** The composer's two text layers as the browser lays them out. */
interface ComposerMetrics {
/** True when the draft is taller than the capped box — the situation under test. */
overflows: boolean
/** Visible height of the textarea's content box: the cap in pixels. */
clientHeight: number
/** Whole lines that fit in the visible box, at the composer's own line-height. */
visibleLines: number
/** The textarea's scroll offset, which the caret and the selection follow. */
inputScrollTop: number
/** The backdrop's scroll offset, which every visible glyph follows. */
backdropScrollTop: number
/** True when the two layers agree — the coupling this scenario exists for. */
layersAgree: boolean
/**
* Top of the LAST draft line relative to the visible box's top, in pixels: at
* most `clientHeight` when that line is on screen. This is the reported
* symptom as a number — with the layers uncoupled the backdrop stays at offset
* 0, so the last line sits a full draft-height below the box.
*/
lastLineOffset: number
/** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
firstLineOffset: number
/** Furthest the textarea can scroll. */
inputMax: number
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
backdropMax: number
/** Content width the textarea wraps at. */
inputWrapWidth: number
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
backdropWrapWidth: number
/** Content width the hidden auto-grow mirror wraps at — it decides the box's height. */
mirrorWrapWidth: number
}
/**
* Measure both composer layers in the page.
* @param page - the page under test.
* @returns the two layers' offsets and where the draft's first and last lines sit.
*/
function measureComposer(page: Page): Promise<ComposerMetrics> {
return page.evaluate(({ first, last }) => {
const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
if (input === null) throw new Error('no live composer textarea in the DOM')
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
// The hidden auto-grow mirror: the textarea's next sibling, and the layer
// that decides the box's height, so its wrap width matters as much as the
// two that carry glyphs.
const mirror = input.nextElementSibling
if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
const box = input.getBoundingClientRect()
// The draft carries no chips or claim token, so the decoration walk emits it
// as one text node — the backdrop's first, ahead of the trailing-line
// sentinel React renders as a second one. Both markers live in that first
// node, which is what the Range below needs.
const text = backdrop.firstChild
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
const offsetOf = (marker: string): number => {
const at = text.data.indexOf(marker)
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
const range = document.createRange()
range.setStart(text, at)
range.setEnd(text, at + marker.length)
return range.getBoundingClientRect().top - box.top
}
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
// Each layer's own maximum, probed by asking for an impossible offset and
// reading back what it clamped to, then restored. Reading scrollHeight -
// clientHeight instead would compute the maximum rather than observe it.
const restore = input.scrollTop
const restoreBackdrop = backdrop.scrollTop
input.scrollTop = 1e7
backdrop.scrollTop = 1e7
const inputMax = input.scrollTop
const backdropMax = backdrop.scrollTop
input.scrollTop = restore
backdrop.scrollTop = restoreBackdrop
return {
inputMax,
backdropMax,
inputWrapWidth: input.clientWidth,
backdropWrapWidth: backdrop.clientWidth,
mirrorWrapWidth: mirror.clientWidth,
overflows: input.scrollHeight > input.clientHeight,
clientHeight: input.clientHeight,
visibleLines: Math.floor(input.clientHeight / lineHeight),
inputScrollTop: input.scrollTop,
backdropScrollTop: backdrop.scrollTop,
layersAgree: input.scrollTop === backdrop.scrollTop,
lastLineOffset: offsetOf(last),
firstLineOffset: offsetOf(first),
}
}, { first: FIRST_MARKER, last: LAST_MARKER })
}
/**
* Render the golden body.
*
* Absolute glyph coordinates are deliberately absent: they depend on font
* metrics and would make the fixture fail on a machine that measures text
* differently — a golden that needs re-recording per platform documents the
* platform, not the change. What is recorded is the cap, the layer agreement,
* and which lines are on screen, each a comparison that survives any layout
* keeping the coupling.
* @param top - metrics with the draft scrolled to its start.
* @param bottom - metrics with the draft scrolled to its end.
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string {
return [
'# Composer draft scrolling (14-line cap, two text layers)',
'',
'## At the start of the draft',
'',
`- draft overflows the capped box: ${String(top.overflows)}`,
`- visible lines: ${String(top.visibleLines)}`,
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
`- all three layers wrap at one width: ${String(
top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
)}`,
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
`- glyph layer tracks it: ${String(top.layersAgree)}`,
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
`- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
'',
'## Scrolled to the end of the draft',
'',
`- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
`- glyph layer tracks it: ${String(bottom.layersAgree)}`,
`- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
`- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
'',
'## Draft ending in a newline, scrolled to the end',
'',
`- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
`- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
`- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
].join('\n').trimEnd()
}
describe('web e2e: composer draft scrolling', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
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 })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
await page.locator('textarea:enabled').first().fill(DRAFT)
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('caps the draft box and keeps both text layers at the start', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
// Vacuity guard: without an overflowing draft there is nothing to scroll and
// every assertion below holds trivially.
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
// Typing the draft left the caret — and the box — at its end, so reach the
// start by the same gesture a user would, and leave it there for the wheel
// case below.
await page.locator('textarea:enabled').first().hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
const metrics = await measureComposer(page)
// The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
// 14 x 24px lines). The count, not the pixels: it is the figma constant and
// survives a device-pixel-ratio change.
expect(metrics.visibleLines).toBe(14)
// Resting state: the draft's head is what a 40-line draft shows, and its
// tail is far below the box. Both layers sit at the origin, which is why the
// uncoupled build looks correct until something scrolls.
expect(metrics.inputScrollTop).toBe(0)
expect(metrics.layersAgree).toBe(true)
expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('lays out all three text layers at one wrap width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
// The premise under the mirror, asserted rather than assumed. Only .input
// scrolls, so only .input can lose content width to a scrollbar that
// consumes layout space; a narrower .input wraps a long draft onto more
// lines, ends up taller, and its larger maximum makes the mirrored offset
// clamp below the caret. Measured on a standalone harness, an 8px width
// difference is worth 2 to 5 lines on a wrap-sensitive draft.
//
// This holds on the lane's engine and is what a regression would break —
// it is NOT vacuous: measured on the same app, WebKit reports 768 against
// 776 here, which is the divergence the Agent Note records as a
// pre-existing, engine-specific limitation. The mirror is unaffected there
// today because the extents still agree; this assertion is what would
// notice if the lane's engine ever moved into the same state.
const metrics = await measureComposer(page)
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
// The mirror decides the box height, so it belongs in the same equality —
// were it alone to wrap wider, the box would be measured too short and
// clip content before the 14-line cap, with every other assertion green.
expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
const input = page.locator('textarea:enabled').first()
await input.hover()
// One delta past the whole draft: the textarea clamps at its own end, and
// the wheel-chaining handler leaves it native because the box is not yet at
// its edge when the gesture starts (the chaining itself is owned by the
// unit spec).
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const metrics = await measureComposer(page)
// The coupling, stated directly.
expect(metrics.layersAgree).toBe(true)
// The reported symptom, stated as what the user sees: the end of the draft
// is on screen and its beginning is not. On the uncoupled build the glyph
// layer stays at offset 0, so `lastLineOffset` is still a full draft below
// the box and `firstLineOffset` is still 0 — the text never moved.
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
expect(metrics.firstLineOffset).toBeLessThan(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('typing at the end of a scrolled draft keeps the layers together', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
// The other way the box moves. Typing at the caret — parked at the draft's
// end by the wheel gesture — scrolls it into view, which is a `scroll` like
// any other; this pins that an edit is not a separate case needing its own
// mirror, which is why one listener is the whole implementation.
const input = page.locator('textarea:enabled').first()
await input.press('End')
await input.pressSequentially(' tail')
const metrics = await measureComposer(page)
expect(metrics.layersAgree).toBe(true)
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
// The layers reserve a final line box on different terms, so this shape is
// the one that separates equal extents from a mirror that clamps early.
const input = page.locator('textarea:enabled').first()
await input.fill(DRAFT_TRAILING_NEWLINE)
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
const extents = await measureComposer(page)
// The invariant the sentinel exists for. Without it the textarea measured
// 652 against the backdrop's 628 — one 24px line apart.
expect(extents.backdropMax).toBe(extents.inputMax)
await input.hover()
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
}, { timeout: 10_000 }).toBe(true)
const bottom = await measureComposer(page)
// At the very bottom the glyphs are level with the caret, not a line behind.
expect(bottom.layersAgree).toBe(true)
expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed composer scroll geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
const input = page.locator('textarea:enabled').first()
// Restore the pristine draft (the edit case appended to it) and return to
// its start, both through ordinary gestures.
await input.fill(DRAFT)
await input.hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
const top = await measureComposer(page)
await input.hover()
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const bottom = await measureComposer(page)
await input.fill(DRAFT_TRAILING_NEWLINE)
await input.hover()
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
}, { timeout: 10_000 }).toBe(true)
const trailingNewline = await measureComposer(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// Zero model calls, so the scenario records no session fixture: 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

@@ -66,7 +66,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -0,0 +1,84 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
describe('core Web profile', () => {
let scaffold: WebScaffold
let agentHandle: AgentHandle
beforeAll(async () => {
scaffold = await launchWebScaffold({
extraOverlayPath: CORE_WEB_OVERLAY,
toolsMode: 'native',
})
agentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('core-web-profile-smoke'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
})
afterAll(async () => {
const failures: unknown[] = []
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
})
it('boots and executes both tools through the shipped Web composition', async () => {
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
const signal = new AbortController().signal
const bash = await scaffold.ctx.tools.execute({
signal,
callId: CallId('core-web-bash-smoke'),
name: 'bash',
arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
agent: agentHandle.agent,
})
const editor = await scaffold.ctx.tools.execute({
signal,
callId: CallId('core-web-editor-smoke'),
name: 'str_replace_editor',
arguments: { command: 'view', path: seedPath },
agent: agentHandle.agent,
})
const text = (result: typeof bash): string => result.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
.trimEnd()
expect({
tools: scaffold.ctx.tools.schemas().map(tool => tool.name),
bash: text(bash),
editor: text(editor),
}).toMatchInlineSnapshot(`
{
"bash": "CORE_WEB_BASH_OK",
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
1 CORE_WEB_EDITOR_OK
2",
"tools": [
"bash",
"str_replace_editor",
],
}
`)
const entries = [...scaffold.ctx.loader.entries()]
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
})
})

View File

@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await appFrame(page).waitFor({ timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -50,7 +50,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
@@ -93,7 +93,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
try {
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(activePage)
await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
const input = activePage.locator('textarea').first()
await activePage.getByRole('button', { name: 'Commands' }).click()
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
@@ -172,10 +172,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Host: the session's durable header cwd is the workspace flow's
// create-by-name target (<workspaceRoot>/workspace, the composer's
// default draft name) — the proof the send went through workspace
// materialization rather than a bare default-cwd session.
// Host: the session's durable header cwd is the folder the workspace
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
// send went through workspace materialization rather than a bare
// default-cwd session.
const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')

View File

@@ -27,14 +27,15 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// One golden per interactive end-state: what the user is left looking at
// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface)
// gap as a reviewable artifact: NO error copy in the tree), and after retry
// recovery — three genuinely different terminal surfaces of one fixture.
// One golden pins the stable mid-turn loading state; the other three capture
// what the user is left looking at after cancel, after a non-retryable failure,
// and after retry recovery.
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
const MODE = webSnapshotMode()
const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
// The recorded base: one text-only turn whose derived script the sidecars
// patch. Kept deliberately tool-free so the derived script is exactly one
@@ -95,7 +96,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}
/**
@@ -133,6 +134,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// The marker IS the synchronization: the stream is provably parked in the
// hang (prefix chunks delivered to the loop) before the stop click.
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
await expect.poll(
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
@@ -151,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => {
await launch(() => ({
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }],
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
}))
onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth'))
const { settled } = await sendPrompt()
@@ -159,28 +166,28 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
// AUTH is outside llm-retry's retryable set: no retry record.
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
// Product gap found by this lane, pinned as-is: the client consumes no
// agent/error frames and a pre-chunk failure freezes no partial, so THIS
// failure renders no error copy anywhere — the user sees the send simply
// stop. FIXME(web-error-surface): assert visible error text here once the
// web UI grows an error rendering; until then the pinned contract is
// "no crash, composer recovers, turn logged as error".
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
// The blank workspace also has an enabled composer. Wait for the driven
// session's only visible message before capturing its no-error-copy state.
await expect.poll(() => page.getByText(PROMPT, { exact: true }).first().isVisible(), { timeout: 10_000 }).toBe(true)
// Golden of the same gap: the prompt bubble alone, no error copy in the
// tree — the diff that changes when web-error-surface lands.
const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
await errorStatus.waitFor({ timeout: 10_000 })
expect(await errorStatus.textContent()).toContain('API key is invalid')
expect(await errorStatus.textContent()).toContain('AUTH')
expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE)
await page.getByRole('tab', { name: 'Trajectory' }).click()
const requestMarker = page.locator('tr[data-request-only="true"]').last()
.getByRole('button', { name: /Request #/ })
await requestMarker.click()
await page.getByText('API key is invalid', { exact: true }).waitFor({ timeout: 10_000 })
expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => {
await launch(() => ({
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }],
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
}))
const { settled } = await sendPrompt()
await settled
@@ -231,7 +238,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md',
'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
])
})
})

View File

@@ -64,14 +64,14 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). User has three actions; each turn's last content
// assistant has copy + branch.
// hover/focus-within). User and each turn's last content assistant both
// have copy + branch.
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await copyButtons.first().focus()
await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
}, 60_000)
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {

View File

@@ -20,7 +20,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
@@ -37,7 +37,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -43,7 +43,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace;
// the read targets must live in that session cwd (pre-creation is safe:
// create-by-name adopts an existing directory).
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')

View File

@@ -9,12 +9,18 @@ import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
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 MODE = webSnapshotMode()
@@ -26,9 +32,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const browserConsole: string[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
page.on('console', message => browserConsole.push(message.text()))
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
@@ -42,16 +49,61 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
it('stores a key write-only and observes configured state without restarting', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
await dialog.waitFor({ timeout: 15_000 })
expect(await dialog.getByRole('textbox').count()).toBe(0)
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
expect(await welcome.locator('button').count()).toBe(1)
const mask = page.locator('[class*="onboardingMask"]')
expect(await mask.count()).toBe(1)
const maskStyles = await mask.evaluate((mask) => {
const style = getComputedStyle(mask)
const rect = mask.getBoundingClientRect()
return {
position: style.position,
left: style.left,
right: style.right,
top: style.top,
bottom: style.bottom,
background: style.backgroundColor,
backdropFilter: style.backdropFilter,
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
}
})
expect(maskStyles).toEqual({
position: 'absolute',
left: '0px',
right: '0px',
top: '80px',
bottom: '0px',
background: 'rgba(0, 0, 0, 0.24)',
backdropFilter: 'blur(2px)',
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
})
// Closing the process/page before acknowledgement writes nothing, so the
// same durable profile presents the notice again after reload.
const firstReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
await credentialStep.waitFor({ timeout: 15_000 })
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
await dialog.getByRole('button', { name: '前往配置' }).click()
await dialog.waitFor({ state: 'detached', timeout: 15_000 })
await credentialStep.getByRole('button', { name: '前往配置' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
const keyInput = settings.getByLabel('API 密钥', { exact: true })
await keyInput.waitFor({ timeout: 10_000 })
@@ -78,6 +130,29 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
{ timeout: 10_000 },
).toBe('已配置——输入新值可替换')
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
const secondReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
// A different stored copy version represents an intentional version bump:
// the welcome step returns even though the credential is already ready.
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
}])
const thirdReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
expect((await page.content()).includes(secret)).toBe(false)
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
@@ -86,6 +161,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
})
})

View File

@@ -0,0 +1,171 @@
// Web acceptance for current sandbox-policy context. A real Chromium drives
// the shipped /permission command through all three presets; record mode uses
// the real provider, while replay keeps the same provider-authored behavior
// keyless. Assertions read the exact durable header, runtime-context messages,
// and tool calls, so assistant prose alone cannot satisfy the scenario.
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 { canonicalPath } from '@deepseek-ai/dsh-sandbox'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/permission-policy-context', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const PROMPTS = [
'Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy.',
'Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools.',
'Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools.',
'Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion.',
] as const
const PRESET_LABELS = ['Read Only', 'Full access', 'Workspace Write'] as const
function requestSystems(events: readonly SessionEvent[]): string[] {
return events.flatMap((event) => {
if (event.type !== 'request/header') return []
return typeof event.data.header.system === 'string' ? [event.data.header.system] : []
})
}
function runtimeContexts(events: readonly SessionEvent[]): string[] {
return events.flatMap((event) => {
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return []
return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
})
}
function assistantTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap((event) => {
if (event.type !== 'assistant/message') return []
const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('').replaceAll('**', '')
return text.length === 0 ? [] : [text]
})
}
function callArgs(event: Extract<SessionEvent, { type: 'tool/call' }>): Record<string, unknown> {
return JSON.parse(event.data.arguments) as Record<string, unknown>
}
describe('web e2e: current sandbox policy reaches the model before tools', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let disposeApproval: (() => void) | undefined
let sessionWorkspace: string | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE })
disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
sessionWorkspace = session.header.cwd
sessionEvents.push(event)
})
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 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
disposeApproval?.()
await scaffold?.close()
})
it('switches read-only, danger-full-access, and workspace-write through the real GUI command path', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-permission-policy-context'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
}
const input = page.locator('textarea').first()
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
await input.fill(`/permission ${preset}`)
await input.press('Enter')
await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPTS[index] as string)
await input.press('Enter')
sessionId = await settled
await expect.poll(() => input.isEnabled(), { timeout: 10_000 }).toBe(true)
}
await input.fill('/permission read-only')
await input.press('Enter')
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPTS[3])
await input.press('Enter')
sessionId = await settled
if (sessionId === undefined) throw new Error('permission-policy scenario completed no model turn')
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
}, 240_000)
it.skipIf(MODE === 'record')('records cache-safe current policy before the corresponding model behavior', async () => {
const systems = requestSystems(sessionEvents)
expect(systems).toHaveLength(1)
expect(systems[0]).not.toContain('Current DSH file policy:')
expect(systems[0]).not.toContain('Approval policy:')
expect(systems[0]).not.toContain('Approval prompts are disabled in this session')
const contexts = runtimeContexts(sessionEvents)
expect(contexts).toHaveLength(4)
expect(contexts[0]).toContain('Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.')
expect(contexts[0]).toContain('Do not refuse a required modification from this policy alone')
expect(contexts[0]).toContain('Approval policy: ask.')
expect(contexts[1]).toContain('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.')
expect(contexts[1]).toContain('Approval prompts are disabled in this session')
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
expect(contexts[2]).toContain(`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(canonicalPath(sessionWorkspace))}. Some platform temporary areas may also be writable.`)
expect(contexts[2]).toContain('Approval policy: ask.')
expect(contexts[2]).not.toContain('Approval prompts are disabled in this session')
expect(contexts[3]).toContain('Current DSH file policy: read-only.')
const answers = assistantTexts(sessionEvents)
expect(answers.length).toBeGreaterThanOrEqual(4)
expect(answers[0]).toMatch(/read-only.*(?:denied|cannot modify|cannot create or edit)/i)
expect(answers[1]).toMatch(/does not restrict.*(?:file operations|(?:write\/edit tools|write and edit tools).*one-shot bash commands)/i)
expect(answers[2]).toBe('WORKSPACE_POLICY_SEEN')
const calls = sessionEvents.filter(
(event): event is Extract<SessionEvent, { type: 'tool/call' }> => event.type === 'tool/call',
)
expect(calls.every(call => call.data.turn === 4)).toBe(true)
expect(calls.length).toBeGreaterThanOrEqual(2)
const firstCall = calls[0]
if (firstCall === undefined) throw new Error('neutral policy task produced no tool call')
expect(callArgs(firstCall)['sandbox_permissions']).toBeUndefined()
expect(calls.some(call => callArgs(call)['sandbox_permissions'] !== undefined)).toBe(true)
expect(sessionEvents.some(event => event.type === 'tool/result'
&& JSON.stringify(event.data).includes('[sandbox: file access denied under read-only mode]'))).toBe(true)
expect(sessionEvents.some(event => event.type === 'approval/asked')).toBe(true)
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
expect(await readFile(join(sessionWorkspace, 'policy-neutral.txt'), 'utf8')).toBe('POLICY_NEUTRAL_OK')
})
it.skipIf(MODE === 'record')('stays clean and keeps the fixture inventory closed', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
})
})

View File

@@ -0,0 +1,11 @@
# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the
# in-browser directory picker. The shipped row is `-auto`, which resolves to
# the native OS chooser on a loopback bind with a local display — an
# interaction a Playwright page cannot drive, so the resolved backend would
# otherwise decide whether the smoke passes. The disable+insert pair mirrors
# apps/web/tests/scaffold.ts.
- id: directory-picker
disabled: true
- insert:
- id: directory-picker-browse
name: '@deepseek-ai/dsh-host-directory-picker-browse'

View File

@@ -54,7 +54,7 @@ describe('web e2e: plan review takeover round trip', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -49,7 +49,7 @@ describe('web e2e: resident question composer round trip', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -68,7 +68,7 @@ describe('web e2e: queue row actions', () => {
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
const input = page.locator('textarea').first()
@@ -131,7 +131,7 @@ describe('web e2e: queue row actions', () => {
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(sessionEvents.filter(event => event.type === 'user/message')).toHaveLength(1)
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])

View File

@@ -48,7 +48,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -11,13 +11,14 @@
// masking its credential, without making a model call.
//
// Composition divergences from `dsh web`, all deliberate, all via include
// patches after the shipped surface overlay: temp persistenceRoot; local skill
// roots confined to the temp workspace; workspace-context disabled (recorded
// fixtures must not embed this repo's AGENTS.md); session-title-llm disabled
// (its fire-and-forget title call would race the loop for the session's replay
// cursor); webserver pinned to port 0 with the built dist; ordinary keyless
// modes disable llm-deepseek and fill the open llm seam post-boot with
// installLlmReplay on the settled root ctx
// patches after the shipped surface overlay, over the SAME tree (never a
// second yml): temp persistenceRoot; host-level skill roots confined to the
// temp workspace while project skill discovery remains real; workspace-context
// disabled (recorded fixtures must not embed this repo's AGENTS.md);
// session-title-llm disabled (its fire-and-forget title call would race the
// loop for the session's replay cursor); webserver pinned to port 0 with the
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
// llm seam post-boot with installLlmReplay on the settled root ctx
// (the plugin-row path discards the ReplayHandle; the direct install keeps
// assertConsumed for the teardown fixture-consumption check).
import { existsSync } from 'node:fs'
@@ -32,6 +33,11 @@ import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import SessionStore, {
@@ -100,6 +106,12 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/**
* Optional product overlay applied after the shipped Web surface and before
* the scaffold's hermetic test patches, matching AppCLIEntry's `--config`
* ordering.
*/
extraOverlayPath?: string
/**
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
* in replay/refresh modes; ignored in record mode (the real adapter
@@ -135,6 +147,19 @@ export interface LaunchOptions {
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/**
* Patch the shipped DeepSeek search row to a deterministic endpoint and
* credential reference. Browser search scenarios keep the real provider and
* credentials seam while avoiding external search traffic and ambient keys.
*/
deepSeekSearch?: {
/** Anthropic-compatible base URL; the provider appends `/messages`. */
baseURL: string
/** Credential reference resolved by the shipped search provider. */
apiKeyEnv: string
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -196,13 +221,17 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
// stops matching a row fails the boot sweep loudly instead of drifting).
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_OVERLAY_PATH)
const extraOverlayPatches = options.extraOverlayPath === undefined
? []
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
const patches: PatchOptions[] = [
...surfacePatches,
...extraOverlayPatches,
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
// storage-json's './.storages' yml default is cwd-relative and resolves
// per write; the scaffold restores the original cwd after boot, so the
// row gets an absolute temp root (removed with the workspace at close).
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
// to an absolute temp root (removed with the workspace at close) so tests
// never write the user's harness home.
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
// Skill discovery is model-visible input. Pin every host-level root inside
// the owned temp world so ~/.dsh, ~/.agents, and a bundled-root env setting
@@ -241,6 +270,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
: [],
...options.deepSeekSearch === undefined
? []
: [{
id: 'web-search-deepseek',
config: {
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
baseURL: options.deepSeekSearch.baseURL,
},
}],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'llm-deepseek', disabled: true }],
@@ -255,6 +293,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
try {
process.chdir(workspaceCwd)
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
// This direct Loader harness supplies the same root-path capability as app-boot.
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
// The shipped CLI deliberately has no dependency on this opt-in package.
@@ -266,6 +306,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
})
await ctx.loader.await()
assertEntriesLoaded(ctx, 'web e2e scaffold')
if (options.welcomeNoticePending !== true) {
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
}])
}
const boundPort = ctx.get('httpServer')?.port
if (boundPort === undefined) {
throw new Error('web e2e scaffold: httpServer service missing after settled boot')

View File

@@ -0,0 +1,170 @@
// @vitest-environment jsdom
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
// the assembled application. The built-boot smoke proves the graph boots but
// carries no behavior assertions by contract; this is the assembled-output check
// that a broken SearchRow registration or a dropped card would fail — the
// per-package suites bench over src and cannot see the bundled wiring.
//
// Keyless and deterministic: the fixture is the fake server, so the grep turn's
// matches, its truncation summary, and its head/tail cap are fixed in the
// fixture, not harvested from a live model. The recovery-footer arm is a pure
// derivation over the result view, pinned at every render site by the
// ui-conversation suite; here the fixture turn exercises the assembled card
// shape and its cap.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt')
const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
dir: 'ui-workspace',
url: '/plugins/ui-workspace.js',
rev: 'fx',
inject: [
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-sidebar',
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
]))
interface FixtureWindow extends Window {
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
__ModuleLoader__?: unknown
}
class ResizeObserverStub {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
}
const win = window as FixtureWindow
let unmount: (() => void) | undefined
/** Normalize a rendered search card to a stable text shape: the kind, the banner
* summary, each file header (path + count), each visible match line, the expand
* control label, and the recovery footer. CSS-module class names carry a
* per-build hash in one of two schemes — ui-primitives emits `_<name>_<hash>`
* (name bounded by underscores), ui-conversation emits `<hash>_<name>` (name at
* the end). `hasClass` matches a module class by its logical name under either,
* without matching a longer name that contains it (`line` must not hit
* `lineNumber`). */
function hasClass(el: Element, name: string): boolean {
return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
}
function cardShape(root: Element): string {
const card = root.querySelector('[data-search]')
if (card === null) return '<no search card>'
const pick = (from: Element, name: string): Element[] =>
[...from.querySelectorAll('*')].filter(el => hasClass(el, name))
const lines: string[] = [`kind=${card.getAttribute('data-search')}`]
const summary = pick(card, 'summary')[0]?.textContent?.trim()
if (summary !== undefined && summary !== '') lines.push(`summary=${summary}`)
for (const header of pick(card, 'fileHeader')) lines.push(`file=${header.textContent?.trim() ?? ''}`)
for (const row of pick(card, 'line')) lines.push(`line=${row.textContent?.trim() ?? ''}`)
const expand = pick(card, 'expand')[0]?.textContent?.trim()
if (expand !== undefined && expand !== '') lines.push(`expand=${expand}`)
const recovery = pick(root, 'searchRecovery')[0]?.textContent?.trim()
if (recovery !== undefined && recovery !== '') lines.push(`recovery=${recovery}`)
return lines.join('\n')
}
beforeEach(() => {
localStorage.clear()
// English pinned before boot so the sidebar's role/text locators stay
// deterministic (the built-boot smoke's convention).
localStorage.setItem('dsh.locale', 'en')
document.title = 'DeepSeek Harness'
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
setTimeout(() => { callback(0) }, 0) as unknown as number)
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
})
afterEach(() => {
act(() => { unmount?.() })
unmount = undefined
cleanup()
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
vi.unstubAllGlobals()
})
describe('assembled search card', () => {
it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => {
history.replaceState(null, '', '/?fixture')
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
})
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
// Wait for chat content to reach the fixture's later turns (the bash sample
// is turn 65, the grep card turn 66).
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
// The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
// by default, so wait for the summary row, then expand it to reach the card.
await waitFor(() => {
const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool'))
expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep')
}, { timeout: 10_000 })
// `data-tool` sits on the ToolRow root; the collapsed row is the expand
// toggle. Click it so the card and its recovery footer mount, then shape the
// whole row (the card lives inside ToolRow's body wrapper).
const grepRow = document.querySelector('[data-tool="grep"]')!
act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) })
await waitFor(() => {
expect(grepRow.querySelector('[data-search]')).not.toBeNull()
}, { timeout: 10_000 })
const shape = cardShape(grepRow)
if (refreshing) {
mkdirSync(dirname(EXPECTED), { recursive: true })
writeFileSync(EXPECTED, shape)
}
await expect(shape).toMatchFileSnapshot(EXPECTED)
})
})

View File

@@ -1,11 +1,12 @@
// Web e2e scenario: seeded history. A recorded session seeded cold through
// the REAL persistence API renders purely from the log — the surface nothing
// else covers: sidebar cold listing, the implicit resume/attach inside the
// history RPC, history-page tool views, and the client fold of historical
// history RPC, history-page tool views, and the client's log-ordered transcript
// events — with ZERO model calls in replay (no replay fixture; a stray stream
// fails loud on the open llm seam). The cold session also carries the one
// keyless command-row surface: an Access-chip pick runs `/permission` on the
// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the
// host, so the settled row's copy has a golden here. The seed is a recorded
// fixture under the
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// live through the composer (real read tool against seeded workspace files)
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
@@ -34,6 +35,90 @@ const SEED_ID = 'seeded-history-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
/**
* Append a complete, valid compaction transaction over the recorded turn's own
* surface. The recording stays model-authentic and reusable; replay adds this
* 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.
* @returns the fixture with a compacted turn appended.
*/
function withCompaction(raw: string): 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 }
})
const surfaceSeqs = events
.filter(event => event.surfaceOp === 'append'
&& (event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'))
.map(event => event.seq)
const first = surfaceSeqs[0]
const last = surfaceSeqs.at(-1)
const tail = events.at(-1)
if (first === undefined || last === undefined || tail === undefined) {
throw new Error('seeded-history compaction requires a non-empty closed surface')
}
// The transaction opens the turn after the recording's last closed one; read
// it from the fixture so a re-recording with a different turn count stays
// valid instead of appending a duplicate turn number.
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
if (typeof lastTurn !== 'number') {
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
}
const turn = lastTurn + 1
let seq = tail.seq + 1
let time = tail.time + 1
/**
* Append one event at the next seq/time.
* @param event - the event body, without seq/time.
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
*/
const at = (event: Record<string, unknown>): number => {
const taken = seq++
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' } } } })
const startSeq = at({ type: 'compact/start', data: { turn } })
const summarySeq = at({
type: 'compact/summary',
data: {
summary: [{
type: 'text',
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
}],
shadowedRange: { start: first, end: last },
shadowedSeqs: surfaceSeqs,
shadowedTokenCount: 10_000,
provider: 'snapshot',
model: 'snapshot-compactor',
},
})
at({
type: 'user/message',
data: {
content: [{
type: 'text',
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'compact' },
},
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn } })
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
return `${lines.join('\n')}\n`
}
describe('web e2e: seeded history renders through cold resume', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -42,7 +127,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace
// (the composer's default draft name); the read-tool targets must live in
// that session cwd. Pre-creating the directory is safe: create-by-name
// adopts an existing directory.
@@ -53,7 +138,7 @@ 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, raw, SEED_ID)
await seedSession(scaffold, withCompaction(raw), SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -119,11 +204,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
await sessionRow.click()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
// Tool cards render from logged tool/call + tool/result alone (views are
// host-recomputed per page; the generic card is the documented default).
const toolRows = page.locator('[data-variant], [data-sample]')
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
// The bug this fixes: the compaction shadowed the whole recorded surface on
// the model side, and the prompt and full tool output are still on screen.
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
@@ -230,6 +319,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
})
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
const marker = page.getByRole('button', { name: /Context compacted/ })
await marker.waitFor({ timeout: 10_000 })
expect(await marker.getAttribute('aria-expanded')).toBe('false')
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
timeout: 5_000,
}).toBe(1)
expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
// Restore the shared page state for any later case.
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
})
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no

View File

@@ -18,7 +18,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
@@ -33,7 +33,9 @@ describe('web e2e: settings modal and General preferences', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
// Chinese browser: the shared page asserts the localized settings surface
// the client derives from it (the English default has its own spec below).
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -215,6 +217,30 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('opens an English browser in English without any stored preference', async () => {
// A second page under a different browser language: nothing is persisted
// for it, so the settings surface must follow the browser rather than the
// product fallback the shared zh page shows.
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
const enTripwire = watchConsole(enPage)
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
try {
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
// This page has no closing inventory spec to sweep its console, so the
// scenario clears both tripwire channels itself.
expect(enTripwire.pageErrors).toEqual([])
expect(enTripwire.warnings).toEqual([])
} finally {
await enPage.close()
}
}, 90_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])

View File

@@ -0,0 +1,83 @@
// Boots the shipped Web composition over the built dist this lane already uses
// and asserts what that composition produces: the model-visible tool catalog
// and the sandbox/approval knobs it ships with. No browser and no model call —
// these are composition facts, and the browser scenarios in this lane cover the
// surface itself.
import { tmpdir } from 'node:os'
import { afterEach, expect, it } from 'vitest'
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/**
* The catalog the shipped Web composition puts in front of the model, minus the
* ripgrep-dependent pair below. The absences are deliberate, not incidental
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
* sandbox row confines, `web_fetch` chooses its own request target, and
* `mcp_*` servers spawn outside `ctx.bash`. The composition Agent Note owns the
* rationale and its sources.
*/
const EXPECTED_TOOLS = [
'ask_user_question',
'bash',
'create_goal',
'edit',
'exit_plan_mode',
'get_goal',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'skill',
'str_replace_editor',
'subagent',
'subagent_fork',
'task_kill',
'task_list',
'task_output',
'todo_write',
'update_goal',
'web_search',
'workflow',
'write',
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
let scaffold: WebScaffold | undefined
afterEach(async () => {
await scaffold?.close()
scaffold = undefined
})
it('assembles the shipped Web catalog and keeps its access default', async () => {
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name)))
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and
// keeps a future boundary test from being run inside /tmp — where an
// "escape" write succeeds by design and reads as a sandbox failure.
expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual(
expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]),
)
// The Web surface keeps its shipped access default; the base's confined one
// reaches the TUI. Pinning both keeps a base change from moving Web silently.
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('danger-full-access')
expect(scaffold.ctx.approval.config.policy).toBe('never')
}, 120_000)

View File

@@ -84,7 +84,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -20,7 +20,7 @@ import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -163,6 +163,8 @@ describe('dsh web keyless CLI smoke', () => {
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-no-call',
DSH_HOME: join(sessionsDir, '.dsh'),
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -188,8 +190,12 @@ describe('dsh web keyless CLI smoke', () => {
mkdirSync(join(workspace, '.git'))
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
interface NativeProviderRequest {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
}
let resolveProviderRequest!: (request: NativeProviderRequest) => void
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
resolveProviderRequest = resolve
})
const provider = createServer((request, response) => {
@@ -197,7 +203,7 @@ describe('dsh web keyless CLI smoke', () => {
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
@@ -222,6 +228,7 @@ describe('dsh web keyless CLI smoke', () => {
DEEPSEEK_API_KEY: 'keyless-web-workspace',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_HOME: join(workspace, '.dsh'),
DSH_AGENTS_HOME: join(workspace, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -241,6 +248,8 @@ describe('dsh web keyless CLI smoke', () => {
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 workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
expect(workspaceMessage).toMatchInlineSnapshot(`
@@ -256,6 +265,13 @@ describe('dsh web keyless CLI smoke', () => {
"role": "user",
}
`)
expect(captured.tools?.map(tool => tool.function?.name)
.filter(name => name === 'web_search' || name === 'web_fetch'))
.toMatchInlineSnapshot(`
[
"web_search",
]
`)
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
@@ -402,6 +418,7 @@ describe('dsh web keyless CLI smoke', () => {
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_TOOLS_MODE: 'code',
DSH_HOME: join(workspace, '.dsh'),
DSH_AGENTS_HOME: join(workspace, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -450,17 +467,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
const port = await probeFreePort()
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
// the global Harness home inside the temp world; tsx also needs the repo's
// loader and tsconfig paths pointed at explicitly.
// the host-level Harness and shared-agent homes inside the temp world; tsx
// also needs the repo's loader and tsconfig paths pointed at explicitly.
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
[
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
// Pin the in-browser picker: the shipped `-auto` row would resolve to
// the native OS chooser on this bind, and no page can drive that.
'--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
],
{
cwd: sessionsDir,
env: {
...process.env,
DSH_HOME: join(sessionsDir, '.dsh'),
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -495,8 +518,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
it('2+3 empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
// This scenario spawns its own server against a fresh $DSH_HOME, so the
// first-run welcome notice is unacknowledged and its overlay owns pointer
// events (the shared scaffold acknowledges it before boot instead). The
// notice is anchored structurally, not by its copy: this spec sits in the
// client TypeScript program, which does not reference the package that
// owns the strings.
const welcome = page.locator('[class*="onboardingOverlay"]')
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button').click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
// Fresh world: connect a Workspace so the composer starts live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, sessionsDir)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
@@ -20,7 +22,7 @@
- img
- text: Code Run bash echo and catch missing file read
- img
- text: Bash Echo CODE_ROUND_OK
- text: Bash Echo CODE_ROUND_OK Failed
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
- img
- text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
@@ -42,4 +44,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 52% Input 17.2K tok · Output 252 tok
- text: 1 turns · 2 steps Tool call {{duration}} Context 7% of 128K Cache hit 52% Input 17.2K tok · Output 252 tok

View File

@@ -0,0 +1,25 @@
# Composer draft scrolling (14-line cap, two text layers)
## At the start of the draft
- draft overflows the capped box: true
- visible lines: 14
- both layers share one scroll extent: true
- all three layers wrap at one width: true
- textarea scroll offset: 0px
- glyph layer tracks it: true
- first draft line is on screen: true
- last draft line is on screen: false
## Scrolled to the end of the draft
- textarea moved: true
- glyph layer tracks it: true
- first draft line has scrolled out above: true
- last draft line is on screen: true
## Draft ending in a newline, scrolled to the end
- both layers share one scroll extent: true
- glyph layer tracks the caret: true
- last draft line is on screen: true

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to:":
- img
- img
@@ -57,4 +59,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 77% Input 66.5K tok · Output 312 tok
- text: 1 turns · 4 steps Tool call {{duration}} Context 13% of 128K Cache hit 77% Input 66.5K tok · Output 312 tok

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img
@@ -37,4 +39,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 99% Input 15.7K tok · Output 111 tok
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 99% Input 15.7K tok · Output 111 tok

View File

@@ -1,6 +1,7 @@
- listbox "Trigger suggestions":
- text: Commands
- option "goal set or view the goal for a long-running task" [selected]
- option "compact Compact older conversation history" [selected]
- option "goal set or view the goal for a long-running task"
- option "permission Switch the permission preset (sandbox mode + approval policy)"
- option "plan Enter or leave plan mode"
- option "model Select the model for this conversation"

View File

@@ -7,7 +7,7 @@
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- button "Add workspace":
- img
- button "Search sessions":
- img
@@ -16,7 +16,7 @@
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- treeitem "New Session" [selected]
- button "Settings":
- img
- text: Settings

View File

@@ -7,7 +7,7 @@
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- button "Add workspace":
- img
- button "Search sessions":
- img
@@ -16,7 +16,7 @@
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- treeitem "New Session" [selected]
- button "Settings":
- img
- text: Settings

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
@@ -29,4 +31,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 21 tok
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 21 tok

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- text: Stopped
- button "Copy":

View File

@@ -9,8 +9,13 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- status:
- text: This turn failedAPI key is invalid
- code: AUTH
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -0,0 +1,25 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- 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.":
@@ -31,4 +33,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 79 tok
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 79 tok

View File

@@ -10,8 +10,6 @@
- tooltip "Copy"
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img

View File

@@ -1,6 +1,5 @@
- dialog "添加一个 API Key 开始使用":
- region "添加一个 API Key 开始使用":
- heading "添加一个 API Key 开始使用" [level=2]
- button "稍后配置":
- img
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
- button "稍后配置"
- button "前往配置"

View File

@@ -0,0 +1,9 @@
- region "内测声明":
- heading "内测声明" [level=2]
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
- paragraph:
- text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
- button "继续"

File diff suppressed because one or more lines are too long

View File

@@ -10,8 +10,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- '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
@@ -42,4 +44,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 51% Input 10.2K tok · Output 346 tok
- text: 1 turns · 2 steps Tool call {{duration}} Context 4% of 128K Cache hit 51% Input 10.2K tok · Output 346 tok

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img
@@ -37,4 +39,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 95% Input 8.6K tok · Output 180 tok
- text: 1 turns · 2 steps Tool call {{duration}} Context 3% of 128K Cache hit 95% Input 8.6K tok · Output 180 tok

View File

@@ -9,9 +9,12 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"
- textbox "Message the agent"
- button "Commands":

View File

@@ -9,9 +9,12 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages" [disabled] [expanded]
- list:
- listitem:

View File

@@ -9,9 +9,12 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- list:
- listitem:
- text: Edited queue item

View File

@@ -0,0 +1,27 @@
kind=matches
summary=显示 9 / 共 42 处匹配 · 3 个文件
file=packages/client/ui-primitives/src/SearchBlock.tsx3
file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
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)
expand=… 其余 4 行
recovery=Found 9 of 42 matches
packages/client/ui-primitives/src/SearchBlock.tsx
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
Line 138: export function SearchBlock(props: SearchBlockProps) {
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
packages/client/ui-conversation/src/client/contract/search-card-model.ts
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
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)
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)

View File

@@ -9,8 +9,6 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
@@ -35,6 +33,9 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- img
- img

View File

@@ -9,8 +9,6 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
@@ -35,6 +33,9 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- img
- img

View File

@@ -9,16 +9,20 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- 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
- text: 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.
- text: Running
- button "Ask question waiting":
- img
- img
- text: Ask question waiting
- status: Deep diving...
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- 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
@@ -38,4 +40,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 156 tok
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 98% Input 15.8K tok · Output 156 tok

View File

@@ -0,0 +1,12 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"}
{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}
{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}}
{"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":7,"time":1785456000008,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"SEARCH_DONE"}}}
{"type":"assistant/chunk","seq":8,"time":1785456000009,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SEARCH_DONE"}}}}
{"type":"assistant/chunk","seq":9,"time":1785456000010,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":10,"time":1785456000011,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}

View File

@@ -0,0 +1,34 @@
- banner:
- navigation "Session hierarchy":
- button "Use web_search to search exactly" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- button "Context injection":
- img
- img
- text: Context injection
- button "Search DeepSeek Harness snapshot search":
- img
- img
- text: Search DeepSeek Harness snapshot search
- paragraph: SEARCH_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- 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

View File

@@ -0,0 +1,119 @@
// Web e2e scenario: startup auto-selection keeps the hero on screen.
//
// A page load with a workspace already registered runs
// `WorkspacesService.startInitialSelection`: it connects the most recent
// workspace and opens its blank session. `openState` flips to `loading` the
// moment `open()` lands, which used to drive `data-phase=settling` on the
// conversation root — `visibility:hidden` over the composer seat and the
// header for the whole `session.history` round-trip, so the center column went
// blank and repainted, reading as a full-page refresh on every launch.
//
// The unit spec pins the phase condition over hand-built stores. What only the
// assembled application can show is that the path a user actually takes
// reaches it: the real selection service, the real client session opening over
// the real /api transport, and a real browser deciding what is painted.
//
// The round-trip against a loopback host is far too fast to observe, so this
// scenario HOLDS the `session.history` response open at the browser's network
// boundary and asserts the visible frame while it is in flight. That gate is
// what makes the assertions non-vacuous: with the exemption reverted the held
// window is exactly when `settling` is painted and the composer is hidden.
//
// Zero model calls: registering a workspace and opening its blank session are
// host RPCs with no model involvement. A stray stream would fail loud with
// NO_ADAPTER.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
/** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */
const HISTORY_ROUTE = '**/api/session.history'
/**
* The conversation root's own phase attribute. `div` disambiguates it from the
* composer textarea, which carries an unrelated `data-phase` of its own.
*/
const ROOT_PHASE = 'div[data-phase]'
/** Every distinct `data-phase` the conversation root shows, in order, across one page load. */
function recordedPhases(page: Page): Promise<string[]> {
return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
}
describe('web e2e: startup auto-selection', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
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 })
// A registered workspace is the precondition for auto-selection: the first
// load has nothing to select, so the reload below is the path under test.
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
// Runs before any page script on the reload below, so the first phase the
// root ever renders is recorded, not just the ones after a listener attaches.
await page.addInitScript(() => {
const phases: string[] = []
;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
setInterval(() => {
const phase = document.querySelector('div[data-phase]')?.getAttribute('data-phase')
if (phase === null || phase === undefined) return
if (phases[phases.length - 1] !== phase) phases.push(phase)
}, 8)
})
let releaseHistory = (): void => {}
const historyHeld = new Promise<void>((resolve) => { releaseHistory = resolve })
let historyRequested = (): void => {}
const historyInFlight = new Promise<void>((resolve) => { historyRequested = resolve })
let gated = false
await page.route(HISTORY_ROUTE, async (route) => {
// Only the auto-selection's own round-trip is held; later pages must not
// deadlock behind a gate this test has already released.
if (gated) { await route.continue(); return }
gated = true
historyRequested()
await historyHeld
await route.continue()
})
const warningsBefore = tripwire.warnings.length
await page.reload({ waitUntil: 'commit' })
await historyInFlight
// The frame a user sees while the session is still opening: hero phase, the
// hero title, and a composer that is actually painted (`settling` hides the
// seat with `visibility:hidden`, which Playwright reports as not visible).
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
expect(await page.locator('textarea').first().isVisible()).toBe(true)
releaseHistory()
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
.waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
// Settling is not merely absent from the frame sampled above: the root
// never entered it at any point of the load.
expect(await recordedPhases(page)).toEqual(['hero'])
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
})

View File

@@ -72,7 +72,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -1,6 +1,7 @@
// Shared plumbing for the web smoke tests (dist location, free port, failure shots).
import { existsSync, mkdirSync } from 'node:fs'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
@@ -9,11 +10,18 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/**
* Browser language a page must advertise to boot into the product's Chinese
* surface: with no stored preference the client derives its initial locale
* from the browser, and Playwright's default browser asks for English.
*/
export const ZH_BROWSER_LOCALE = 'zh-CN'
/**
* Open the standard browser-test page with English selected before client
* boot. This keeps role locators and goldens deterministic across localized
* component migrations; the settings locale scenario deliberately bypasses
* this helper to cover the product's default Chinese state.
* component migrations; the scenarios asserting the Chinese surface bypass
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
* @param browser - Playwright browser owning the page.
* @param height - Viewport height; width is fixed to the lane baseline.
* @returns the initialized page.
@@ -48,23 +56,32 @@ export function probeFreePort(): Promise<number> {
}
/**
* Drive the hero's workspace picker through its create-by-name dialog until
* the live composer unlocks. A fresh world has no Workspace, so the boot
* Drive the hero's workspace picker through the composed directory dialog
* until the live composer unlocks. A fresh world has no Workspace, so the boot
* lands in the locked view state (startup auto-selection has nothing to
* select); every scenario that types into the composer must connect one
* first. The default name 'workspace' keeps the session header cwd at
* <workspaceRoot>/workspace the materialization proof several scenarios
* first. With nothing to list, the chip gesture raises the dialog directly —
* adding a workspace is the picker's only entry. The directory is staged here
* and adopted through the path editor, which is idempotent across the repeated
* connects a scenario may make; creating a folder from inside the dialog (the
* product's other half of the same route) is covered by
* workspace-management.e2e.ts. The default name 'workspace' keeps the session
* header cwd at <root>/workspace, the materialization proof several scenarios
* assert.
* @param page - the page under test.
* @param name - workspace name typed into the create dialog.
* @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`).
* @param name - folder name staged and adopted as the workspace.
*/
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('New workspace name').fill(name)
await dialog.getByRole('button', { name: 'Create workspace' }).click()
await dialog.getByRole('button', { name: 'Edit path' }).click()
const pathInput = dialog.getByRole('textbox', { name: 'Edit path' })
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
// The pick connected the workspace: the blank session's live composer
// replaces the locked placeholder and enables.
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')

View File

@@ -0,0 +1,207 @@
// Web e2e scenario for the shipped default search composition. A real browser
// drives `web_search`; the model stream is replayed while the real DeepSeek
// provider calls a deterministic local Anthropic-compatible endpoint through
// the real credentials service.
import { readFile } from 'node:fs/promises'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
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 { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const QUERY = 'DeepSeek Harness snapshot search'
const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
const SEARCH_CREDENTIAL = 'snapshot-search-key'
const RESULT_URL = 'https://docs.example.test/search'
interface CapturedSearchRequest {
path: string
apiKey: string | undefined
body: unknown
}
/** Start the deterministic DeepSeek Messages double used by the real provider. */
async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> {
const server = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
captured.push({
path: request.url ?? '',
apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
body: JSON.parse(body) as unknown,
})
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({
content: [
{
type: 'text',
text: 'Found one source.',
citations: [{
type: 'web_search_result_location',
url: RESULT_URL,
cited_text: 'Snapshot search excerpt.',
}],
},
{
type: 'web_search_tool_result',
content: [{
type: 'web_search_result',
url: RESULT_URL,
title: 'Snapshot Search Result',
page_age: '2026-07-31',
}],
},
],
}))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address() as AddressInfo
return { server, baseURL: `http://127.0.0.1:${address.port}` }
}
describe('web e2e: shipped default web search', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let searchServer: Server | undefined
let searchBaseURL: string
let tripwire: ReturnType<typeof watchConsole>
const searchRequests: CapturedSearchRequest[] = []
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
const search = await startSearchServer(searchRequests)
searchServer = search.server
searchBaseURL = search.baseURL
scaffold = await launchWebScaffold({
deepSeekSearch: {
baseURL: search.baseURL,
apiKeyEnv: SEARCH_CREDENTIAL_REF,
},
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
})
await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL)
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
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 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
await new Promise<void>((resolve, reject) => {
if (searchServer === undefined) {
resolve()
return
}
searchServer.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
})
it('drives the recorded search to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
}, 200_000)
it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => {
expect(searchRequests).toHaveLength(1)
expect(searchRequests[0]).toMatchObject({
path: '/messages',
apiKey: SEARCH_CREDENTIAL,
body: {
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
},
})
const auxiliaryRequest = sessionEvents.find(
(event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
event.type === 'web/deepseek-search-llm-request',
)
expect(auxiliaryRequest?.data).toEqual({
endpoint: `${searchBaseURL}/messages`,
apiVersion: '2023-06-01',
body: searchRequests[0]?.body,
})
const searchCall = sessionEvents.find(
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
event.type === 'tool/call' && event.data.name === 'web_search',
)
if (searchCall === undefined) throw new Error('the replayed turn did not call web_search')
const searchResult = sessionEvents.find(
(event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
)
if (searchResult === undefined) throw new Error('web_search produced no durable result')
const content = searchResult.data.message.content[0]
expect(content.isError).toBe(false)
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toContain(`[Snapshot Search Result](${RESULT_URL})`)
expect(searchResult.data.meta).toMatchObject({
sources: [{
url: RESULT_URL,
title: 'Snapshot Search Result',
snippet: 'Snapshot search excerpt.',
publishedAt: '2026-07-31',
}],
truncated: false,
})
})
it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1)
await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
})
})

View File

@@ -1,14 +1,19 @@
// Web e2e scenarios: workspace management — the create-by-name dialog, the
// rename round trip over the real wire (workspace.rename RPC + durable
// registry), duplicate-name pre-check, the flat "In one list" view with its
// persisted group-by preference, and the session hover card. Zero model
// calls: workspace.create/rename are host RPCs with no model involvement,
// and the one session row the flat/hover scenarios need comes from a seeded
// fixture (the seeded-history seed reused verbatim — no new recording).
// Web e2e scenarios: workspace management — adding a workspace through the
// composed directory dialog (its own New folder affordance is the product's
// one creation route), same-basename directory adoption, the rename round
// trip over the real wire (workspace.rename RPC + durable registry), the
// duplicate-name pre-check, the
// flat "In one list" view with its persisted group-by preference, the session
// hover card and row action menu, and the session archive round trip (row
// menu → workspace.archiveSession RPC → durable global set → row hidden
// across reload). Zero model calls: workspace.create/rename/archiveSession
// are host RPCs with no model involvement, and the one session row the
// flat/hover/menu/archive scenarios need comes from a seeded fixture (the
// seeded-history seed reused verbatim — no new recording).
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -25,29 +30,59 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo
const MODE = webSnapshotMode()
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
const SEED_ID = 'workspace-management-web-e2e'
// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled
// to that contract if the shared grace tuning changes.
const POINTER_TRANSIT_MS = 300
const POINTER_HOLD_MS = 600
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
/**
* Drive the in-app browser to a directory via its path-edit affordance,
* confirm it, and wait for the adoption to settle host-side (workspace
* registered + the flow's New-Session agent up), so later test steps can't
* race the in-flight blank-session attach.
* Raise the region header's directory dialog and drive it to a directory via
* the path-edit affordance. Adding is the header button's only action, so
* the click lands in the dialog with no menu in between.
*/
async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
const agentsBefore = scaffold.ctx.agents.list().length
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
async function browseTo(path: string): Promise<Locator> {
await page.getByRole('button', { name: 'Add workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(path)
await dialog.getByLabel('Edit path').press('Enter')
await dialog.getByRole('button', { name: 'Open' }).click()
return dialog
}
/**
* Create a folder inside `parent` through the dialog and adopt it — the
* product's only route to a brand-new workspace directory.
*/
async function addNewFolderWorkspace(parent: string, name: string): Promise<void> {
const dialog = await browseTo(parent)
await dialog.getByRole('button', { name: 'New folder' }).click()
await page.getByLabel('Folder name').fill(name)
await page.getByRole('button', { name: 'Create', exact: true }).click()
// Creating selects the new folder in the listing; Open adopts it.
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(join(parent, name)),
{ timeout: 10_000 },
).not.toBeUndefined()
}
/**
* Adopt an existing directory, waiting for the adoption to settle host-side
* (workspace registered + the flow's New-Session agent up), so later test
* steps can't race the in-flight blank-session attach.
*/
async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
const agentsBefore = scaffold.ctx.agents.list().length
const dialog = await browseTo(path)
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(path),
@@ -83,22 +118,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
await scaffold?.close()
})
it('creates two workspaces by name through the region-header dialog', async () => {
it('adds two workspaces through the dialog, each on a folder it created', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
const createByName = async (name: string): Promise<void> => {
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('New workspace name').fill(name)
await dialog.getByRole('button', { name: 'Create workspace' }).click()
await expect.poll(() => page.getByRole('dialog', { name: 'Create a new workspace' }).count(), { timeout: 10_000 }).toBe(0)
const add = async (name: string): Promise<void> => {
await addNewFolderWorkspace(scaffold.workspaceCwd, name)
// The real workspace materializes in the tree as a group row.
await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
}
await createByName('alpha-ws')
await createByName('beta-ws')
// Durable on the host: both registered, newest first (create prepends).
await add('alpha-ws')
await add('beta-ws')
// Durable on the host: both registered, newest first (create prepends),
// each titled after the folder the dialog made.
const titles = scaffold.ctx.workspace.list().map(workspace => workspace.title)
expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws'])
expect(tripwire.pageErrors).toEqual([])
@@ -164,7 +194,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
// Register the scaffold's existing project directory through the real UI.
await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
await adoptDirectory(scaffold.workspaceCwd, { waitForAgent: true })
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
await workspace.attachSession(SessionId(SEED_ID))
@@ -222,7 +252,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// 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.
await openLocalFolder(scaffold.workspaceCwd)
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
@@ -292,7 +322,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
await openLocalFolder(oldPath)
await adoptDirectory(oldPath)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(oldPath),
{ timeout: 10_000 },
@@ -308,12 +338,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
.getByRole('button', { name: 'Delete workspace' }).click()
await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const create = page.getByRole('dialog', { name: 'Create a new workspace' })
await create.getByLabel('New workspace name').fill(title)
await create.getByRole('button', { name: 'Create workspace' }).click()
await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0)
await addNewFolderWorkspace(scaffold.workspaceCwd, title)
const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
expect(fresh?.id).toBeDefined()
expect(fresh?.id).not.toBe(oldWorkspace.id)
@@ -363,13 +388,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
process.env.HOME = scaffold.workspaceCwd
process.env.USERPROFILE = scaffold.workspaceCwd
try {
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(staged)
await dialog.getByLabel('Edit path').press('Enter')
const dialog = await browseTo(staged)
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
@@ -384,14 +403,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Expand Ungrouped to reveal the seeded session row, then dwell on it
// (the card opens after a 500ms hover delay, portaled to body).
/**
* Expand Ungrouped and return its seeded session row. The only visible child
* is the non-blank persisted Session; the blank Session created while
* adopting the Workspace stays hidden.
* @returns the session row locator, already present.
*/
async function seededSessionRow() {
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
// Initial-current auto-expansion can race this following test's gesture;
// converge on expanded rather than assuming which update wins first.
// Initial-current auto-expansion can race this gesture; converge on
// expanded rather than assuming which update wins first.
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
@@ -399,20 +421,145 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
// The only visible child is the non-blank persisted Session; the blank
// Session created while adopting the Workspace remains hidden.
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
const row = ungroupedSection.locator('[role="treeitem"]').nth(1)
await row.waitFor({ timeout: 10_000 })
return row
}
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Dwell on the seeded row; the card opens after a 500ms hover delay,
// portaled to body.
const sessionRow = await seededSessionRow()
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
await sessionRow.hover()
// Card content: the full title plus the Idle status line (display-only
// card; no aria role — text anchors are the stable selector).
// Card content: the full title plus the Idle status line (no aria role —
// text anchors are the stable selector).
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// Leaving the anchor closes it with no delay.
// The card is REACHABLE: it sits 8px off the row, so getting to it means
// crossing ground that belongs to neither. Hovering it must not dismiss
// it — the regression this scenario guards.
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
await card.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1)
// The full title is the card's primary value: activating anywhere on the
// card writes it through the browser clipboard and localizes the success
// feedback through the English locale seat.
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
const cardHeight = (await card.boundingBox())?.height
await card.click()
const copied = page.getByRole('status').getByText('Copied', { exact: true })
await copied.waitFor({ timeout: 5_000 })
await page.waitForTimeout(POINTER_HOLD_MS)
expect((await card.boundingBox())?.height).toBe(cardHeight)
expect(await copied.isVisible()).toBe(true)
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle)
// Leaving anchor and card together closes it after the grace.
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
const sessionRow = await seededSessionRow()
// The trigger is display:none until its row hovers.
await sessionRow.hover()
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
await trigger.click()
const item = page.getByRole('menuitem', { name: 'Rename' })
await item.waitFor({ timeout: 5_000 })
// Into the list, then back up to the trigger across the 4px gap below it:
// that return trip used to fire the list's pointerleave and close the
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
// the pointer ON the trigger, so entering the list has to come first for
// the return to be a real departure.
await item.hover()
await page.waitForTimeout(POINTER_TRANSIT_MS)
await trigger.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
// ...and back down into the list, which must still be there to enter.
await item.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
// Pointer-leave dismissal still applies once the pointer genuinely leaves.
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
// The seeded session lives under Ungrouped (expanded by the hover-card
// test's gesture; converge again for order independence).
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
await page.waitForTimeout(50)
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
// Anchor on session rows (the rows carrying a session actions button),
// not a positional index, and assert the single-stray assumption loudly
// so a fixture gaining a second stray fails here instead of archiving
// the wrong row. CSS attribute match, not getByRole: the button is
// display:none until its row hovers, and role queries skip hidden nodes.
const sessionRows = ungroupedSection.locator('[role="treeitem"]')
.filter({ has: page.locator('button[aria-label^="Session actions for "]') })
await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1)
const sessionRow = sessionRows.first()
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
// Row menu: hover reveals the actions button; Archive session commits
// without a confirmation dialog (non-destructive: log + accounting stay).
await sessionRow.hover()
await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
await page.getByRole('menuitem', { name: 'Archive session' }).click()
// The row disappears on the archive-set echo; with no other visible
// stray, the whole Ungrouped bucket withdraws.
await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
// Durable on the host: the registry-global set carries the id while the
// session log itself stays in persistence untouched.
expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID))
// Reload: the hidden state is rebuilt from the workspace.list baseline.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// The archived row must not resurface (the Ungrouped bucket itself may
// reappear if selection restore lands on another stray — not this test's
// concern).
expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('opens folders with identical basenames as distinct workspaces', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-duplicate-basename'))
const firstPath = join(scaffold.workspaceCwd, 'same-basename-a', 'xx')
const secondPath = join(scaffold.workspaceCwd, 'same-basename-b', 'xx')
await mkdir(firstPath, { recursive: true })
await mkdir(secondPath, { recursive: true })
await adoptDirectory(firstPath, { waitForAgent: true })
await adoptDirectory(secondPath, { waitForAgent: true })
const matchingWorkspaces = scaffold.ctx.workspace.list()
.filter(workspace => workspace.title === 'xx')
expect(matchingWorkspaces.map(workspace => workspace.path).sort())
.toEqual([firstPath, secondPath].sort())
await expect.poll(
() => page.locator('button[aria-label="Workspace actions for xx"]').count(),
{ timeout: 10_000 },
).toBe(2)
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.warnings).toEqual([])
// The directory-browser aria golden is this spec's one owned artifact;

View File

@@ -24,6 +24,7 @@
"exclude": [
"tests/scaffold.ts",
"tests/scaffold-hermetic.e2e.ts",
"tests/core-web-profile.snapshot.ts",
"tests/live-interactions.e2e.ts",
"tests/question-composer.e2e.ts",
"tests/approval-composer.e2e.ts",
@@ -40,11 +41,16 @@
"tests/seeded-history.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/code-mode-round.e2e.ts",
"tests/composer-draft-scroll.e2e.ts",
"tests/cordis-tool-round.e2e.ts",
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/access-confirmation.e2e.ts"
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/startup-auto-selection.e2e.ts"
],
"references": [
{