Merge origin/master: combine boot-owned settlement with the fail-loud release
Conflicts: apps/cli/src/tui.ts (keep the release install over master's comment rewording), packages/ui/app-boot/README* (master's new installFailLoud row wording plus this branch's release and timeout rows).
This commit is contained in:
@@ -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: 7ef24dc6290af5aafe3eb53609b090fcbfb83ce8
|
||||
README.zh.md: b19d4979351940e925db3b7a616a5c87689770a1
|
||||
README.md: 76d9ed65398322cb9244a31661ee59b60c23f793
|
||||
README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b
|
||||
|
||||
@@ -3,25 +3,39 @@
|
||||
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`, `--dump-config`, `--dump-default-config`), whose `experimental-meta` subcommand is the same TUI over this checkout, whose `experimental-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 — `experimental-upgrade`, `web`, `experimental-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.
|
||||
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 a guided-session entry, and whose `web` subcommand is the browser UI. `meta` and `upgrade` are experimental: each runs only with its `--experimental` flag or with `DSH_EXPERIMENTAL=1` in the environment, and fails loud (stderr, exit 1) otherwise. `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:
|
||||
|
||||
- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config <path>` applies a patch-list overlay instead of the personal overlay, while `--config-replace <path>` boots that file as the complete tree;
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh experimental-meta` is the sole exception, below);
|
||||
- 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`.
|
||||
- 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`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only.
|
||||
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
|
||||
- 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 experimental-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. Experimental meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
|
||||
`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 experimental-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. The command takes no options — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
|
||||
`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. The command takes no options beyond the experimental gate — `--config`, `-p`, and `--resume` fail loud — and seeds 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 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 tell the coding agent its resolved model and session working directory, 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. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index 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 shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one.
|
||||
|
||||
All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
```
|
||||
|
||||
The repository must contain a prepared `.dsh-plugin` package; the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration) documents authoring, nested Plugin paths, the immutable cache, trust boundary, and failure semantics. A failed live edit keeps the last good tree and emits Cordis's `hmr/config-update-failed` event.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -3,25 +3,39 @@
|
||||
[English](README.md) | 中文
|
||||
|
||||
|
||||
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`experimental-meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`experimental-upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`experimental-upgrade`、`web`、`experimental-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 无需任何标志即可使用。
|
||||
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。`meta` 与 `upgrade` 是实验性命令:只有带上各自的 `--experimental` 标志或在环境中设置 `DSH_EXPERIMENTAL=1` 才会运行,否则明确报错(stderr,退出码 1)。`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 界面:
|
||||
|
||||
- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config <path>` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace <path>` 将指定文件作为完整配置树启动;
|
||||
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
|
||||
- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh experimental-meta` 是唯一例外,见下文);
|
||||
- 将 **调用目录** 视为 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`。
|
||||
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。
|
||||
- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。
|
||||
- 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。
|
||||
|
||||
`dsh experimental-meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Experimental meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`。
|
||||
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`。
|
||||
|
||||
`dsh experimental-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 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`)。
|
||||
Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset(`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器;TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。
|
||||
|
||||
三个界面都会使用 `$DSH_HOME/config.yaml`;TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
```
|
||||
|
||||
仓库必须包含已准备的 `.dsh-plugin` 包;[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)说明创作方式、嵌套插件路径、不可变缓存、信任边界和失败语义。实时编辑失败时,最后一个可用树保持运行,并发出 Cordis 的 HMR(热模块替换)事件 `hmr/config-update-failed`。
|
||||
|
||||
已交付的 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`。
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ flowchart LR
|
||||
cfg --> plugin_tui_timer
|
||||
plugin_tui_hmr["hmr<br/>@cordisjs/plugin-hmr"]
|
||||
cfg --> plugin_tui_hmr
|
||||
plugin_tui_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"]
|
||||
cfg --> plugin_tui_repository_plugins
|
||||
plugin_tui_llm["llm<br/>@deepseek-ai/dsh-llm"]
|
||||
cfg --> plugin_tui_llm
|
||||
plugin_tui_session["session<br/>@deepseek-ai/dsh-session"]
|
||||
@@ -42,8 +44,16 @@ flowchart LR
|
||||
cfg --> plugin_tui_telemetry_otel
|
||||
plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
|
||||
cfg --> plugin_tui_subprocess
|
||||
plugin_tui_bash_local["bash-local<br/>@deepseek-ai/dsh-bash-local"]
|
||||
cfg --> plugin_tui_bash_local
|
||||
plugin_tui_sandbox["sandbox<br/>@deepseek-ai/dsh-sandbox-local"]
|
||||
cfg --> plugin_tui_sandbox
|
||||
plugin_tui_sandbox_policy["sandbox-policy<br/>@deepseek-ai/dsh-sandbox-policy"]
|
||||
cfg --> plugin_tui_sandbox_policy
|
||||
plugin_tui_bash_sandbox["bash-sandbox<br/>@deepseek-ai/dsh-bash-sandbox"]
|
||||
cfg --> plugin_tui_bash_sandbox
|
||||
plugin_tui_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
|
||||
cfg --> plugin_tui_approval
|
||||
plugin_tui_permission["permission<br/>@deepseek-ai/dsh-permission"]
|
||||
cfg --> plugin_tui_permission
|
||||
plugin_tui_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
|
||||
cfg --> plugin_tui_tool_bash
|
||||
plugin_tui_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"]
|
||||
@@ -84,10 +94,16 @@ flowchart LR
|
||||
cfg --> plugin_tui_subagent_spawn
|
||||
plugin_tui_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
|
||||
cfg --> plugin_tui_subagent_fork
|
||||
plugin_tui_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
|
||||
cfg --> plugin_tui_tool_subagent_control
|
||||
plugin_tui_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
|
||||
cfg --> plugin_tui_tool_subagent_list_agents
|
||||
plugin_tui_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
|
||||
cfg --> plugin_tui_tool_subagent
|
||||
plugin_tui_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
|
||||
cfg --> plugin_tui_tool_subagent_fork
|
||||
plugin_tui_tool_subagent_report["tool-subagent-report<br/>@deepseek-ai/dsh-tool-subagent-report"]
|
||||
cfg --> plugin_tui_tool_subagent_report
|
||||
plugin_tui_workflow_workerthread["workflow-workerthread<br/>@deepseek-ai/dsh-workflow-workerthread"]
|
||||
cfg --> plugin_tui_workflow_workerthread
|
||||
plugin_tui_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
|
||||
@@ -108,8 +124,6 @@ flowchart LR
|
||||
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"]
|
||||
@@ -126,8 +140,8 @@ flowchart LR
|
||||
cfg --> plugin_tui_system_prompt
|
||||
plugin_tui_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
|
||||
cfg --> plugin_tui_agent_loop
|
||||
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
|
||||
cfg --> plugin_tui_fs_local
|
||||
plugin_tui_fs_sandbox["fs-sandbox<br/>@deepseek-ai/dsh-fs-sandbox"]
|
||||
cfg --> plugin_tui_fs_sandbox
|
||||
plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
|
||||
cfg --> plugin_tui_llm_deepseek
|
||||
```
|
||||
@@ -136,6 +150,7 @@ flowchart LR
|
||||
| --- | --- |
|
||||
| `timer` | `@cordisjs/plugin-timer` |
|
||||
| `hmr` | `@cordisjs/plugin-hmr` |
|
||||
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
|
||||
| `llm` | `@deepseek-ai/dsh-llm` |
|
||||
| `session` | `@deepseek-ai/dsh-session` |
|
||||
| `session-title` | `@deepseek-ai/dsh-session-title` |
|
||||
@@ -151,7 +166,11 @@ flowchart LR
|
||||
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
|
||||
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
|
||||
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
|
||||
| `bash-local` | `@deepseek-ai/dsh-bash-local` |
|
||||
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
|
||||
| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
|
||||
| `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
|
||||
| `approval` | `@deepseek-ai/dsh-user-approval` |
|
||||
| `permission` | `@deepseek-ai/dsh-permission` |
|
||||
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
|
||||
| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` |
|
||||
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
|
||||
@@ -172,8 +191,11 @@ flowchart LR
|
||||
| `subagent` | `@deepseek-ai/dsh-subagent` |
|
||||
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
|
||||
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
|
||||
| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
|
||||
| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
|
||||
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
| `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` |
|
||||
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
|
||||
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
|
||||
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
|
||||
@@ -184,7 +206,6 @@ flowchart LR
|
||||
| `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` |
|
||||
@@ -193,7 +214,7 @@ flowchart LR
|
||||
| `tools` | `@deepseek-ai/dsh-tools` |
|
||||
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
|
||||
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
|
||||
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
|
||||
| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
|
||||
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
|
||||
|
||||
Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml).
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
config:
|
||||
root: ['.']
|
||||
|
||||
# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub
|
||||
# repository Plugin generations. The app registers the DSH-owned runtime even
|
||||
# when the list is empty so a later personal-config edit can load
|
||||
# transactionally; one-shot headless runs consume the startup value only.
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm'
|
||||
|
||||
@@ -131,11 +138,42 @@
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
- id: bash-local
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
# Every shipped product surface starts with the same file-effect boundary.
|
||||
# The environment remains an explicit deployment override; otherwise fresh
|
||||
# sessions pin workspace-write + ask through the permission service below.
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
|
||||
- id: approval
|
||||
name: '@deepseek-ai/dsh-user-approval'
|
||||
config:
|
||||
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
|
||||
|
||||
- id: permission
|
||||
name: '@deepseek-ai/dsh-permission'
|
||||
config:
|
||||
presets:
|
||||
read-only:
|
||||
sandbox: read-only
|
||||
approval: ask
|
||||
workspace-write:
|
||||
sandbox: workspace-write
|
||||
approval: ask
|
||||
danger-full-access:
|
||||
sandbox: danger-full-access
|
||||
approval: never
|
||||
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
@@ -219,17 +257,31 @@
|
||||
config:
|
||||
providerName: fork
|
||||
|
||||
# Continuable background children are selected per delegation tool. The
|
||||
# separately loaded follow-up tool registers the one global `send_message`.
|
||||
- id: tool-subagent-control
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control'
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
backgroundMode: continuable
|
||||
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Optional direct-child return channel; absent from roots and one-shot agents.
|
||||
- id: tool-subagent-report
|
||||
name: '@deepseek-ai/dsh-tool-subagent-report'
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
@@ -278,12 +330,6 @@
|
||||
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:
|
||||
@@ -301,7 +347,9 @@
|
||||
# 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.
|
||||
# protection and the model would choose the request target. Search is a full
|
||||
# auxiliary model request with server-side retrieval, so this shipped DeepSeek
|
||||
# route gets 60s while the provider-neutral tool default remains 30s.
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
@@ -317,6 +365,7 @@
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetch: false
|
||||
searchTimeoutMs: 60000
|
||||
|
||||
# ── rows every surface mounts, whose values each overlay states ──────────────
|
||||
|
||||
@@ -339,10 +388,10 @@
|
||||
config:
|
||||
agents: []
|
||||
|
||||
# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the
|
||||
# TUI states it explicitly because that value is also the session workspace.
|
||||
- id: fs-local
|
||||
name: '@deepseek-ai/dsh-fs-local'
|
||||
# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; the TUI
|
||||
# states it explicitly because that value is also the session workspace.
|
||||
- id: fs-sandbox
|
||||
name: '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per
|
||||
# request from the `llm-deepseek:` settings section over this entry, with the
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-control
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
@@ -45,9 +51,6 @@
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: tool-session-query
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
reasoningEffort: max
|
||||
|
||||
# This single-session app resolves relative paths from the process cwd.
|
||||
- id: fs-local
|
||||
- id: fs-sandbox
|
||||
config:
|
||||
cwd: !!js process.cwd()
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
- id: system-prompt
|
||||
config:
|
||||
persona: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
@@ -36,50 +41,6 @@
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
|
||||
# The web surface replaces the unrestricted local executors with the shared
|
||||
# sandbox policy. Its default preserves the previous unrestricted behavior;
|
||||
# DSH_PERMISSION_MODE and the browser permission picker can confine a session.
|
||||
- insert:
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
- id: approval
|
||||
name: '@deepseek-ai/dsh-user-approval'
|
||||
config:
|
||||
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
|
||||
|
||||
- id: permission
|
||||
name: '@deepseek-ai/dsh-permission'
|
||||
config:
|
||||
presets:
|
||||
read-only:
|
||||
sandbox: read-only
|
||||
approval: ask
|
||||
workspace-write:
|
||||
sandbox: workspace-write
|
||||
approval: ask
|
||||
danger-full-access:
|
||||
sandbox: danger-full-access
|
||||
approval: never
|
||||
|
||||
- id: fs-sandbox
|
||||
name: '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
- id: bash-local
|
||||
disabled: true
|
||||
|
||||
- id: fs-local
|
||||
disabled: true
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
@@ -116,10 +117,11 @@
|
||||
"@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-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
||||
* for the Web/headless surface.
|
||||
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
|
||||
* Everything here is what must exist before the Loader runs: the patch
|
||||
* composition over the shipped base and surface overlay (profile json + CLI
|
||||
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
|
||||
@@ -16,7 +16,13 @@ import { join, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import yaml from 'js-yaml'
|
||||
import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
boot,
|
||||
installFailLoud,
|
||||
loadOverlayPatches,
|
||||
loadPersonalPatches,
|
||||
watchPersonalPatches,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
// Empty type import carries the httpServer Context merge for the port read below.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
@@ -140,8 +146,10 @@ export interface AppCLIEntryOptions {
|
||||
* `$DSH_HOME/config.yaml` overlay is applied instead.
|
||||
*/
|
||||
extraOverlayPath?: string
|
||||
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
|
||||
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
|
||||
dev: boolean
|
||||
/** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */
|
||||
watchPersonalConfig: boolean
|
||||
/** --host when explicitly passed; undefined keeps the yml engineering default. */
|
||||
host?: string
|
||||
/**
|
||||
@@ -155,6 +163,8 @@ export interface AppCLIEntryOptions {
|
||||
workspaceRoot?: string
|
||||
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
|
||||
trustedHosts?: string[]
|
||||
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
|
||||
prepare?: (ctx: Context) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,8 +190,8 @@ export class AppCLIEntry {
|
||||
constructor(private readonly options: AppCLIEntryOptions) {}
|
||||
|
||||
/**
|
||||
* Run the boot chain: patch composition → Loader include boot (dev row
|
||||
* before await) → fail-loud triple.
|
||||
* Run the boot chain: patch composition → Loader installation → surface
|
||||
* preparation → config-tree boot (dev row before await) → fail-loud triple.
|
||||
* @returns the settled root context and the listening port.
|
||||
*/
|
||||
async run(): Promise<{ ctx: Context; port: number }> {
|
||||
@@ -233,11 +243,12 @@ export class AppCLIEntry {
|
||||
// user config. Workspace knowledge stays here.
|
||||
put('webserver', 'distIndex', this.resolveDistIndex())
|
||||
|
||||
this.patches = [...overrides.entries()].map(([id, bag]) => {
|
||||
const generated = [...overrides.entries()].map(([id, bag]) => {
|
||||
const yml = rows.get(id)
|
||||
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
|
||||
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
|
||||
})
|
||||
this.patches = generated
|
||||
|
||||
// Telemetry opt-out: a row can only be turned off at the patch layer
|
||||
// (config cannot disable an entry), and the switch must hold BEFORE the
|
||||
@@ -246,22 +257,36 @@ export class AppCLIEntry {
|
||||
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
|
||||
}
|
||||
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
|
||||
/** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
|
||||
private async bootTree(): Promise<void> {
|
||||
// One include of the shared base with every overlay as a sibling patch
|
||||
// list: patches never cross an include boundary, so nesting them would
|
||||
// silently stop reaching base rows. The surface overlay applies first, then
|
||||
// this entry's profile-json and CLI-flag patches, which therefore win.
|
||||
const patches = [
|
||||
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
|
||||
...loadOverlayPatches('dsh', this.options.overlayPath),
|
||||
...this.options.extraOverlayPath === undefined
|
||||
? loadPersonalPatches('dsh') ?? []
|
||||
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
|
||||
...overlay,
|
||||
...this.patches,
|
||||
]
|
||||
// An explicit --config overlay REPLACES the personal overlay, so there is
|
||||
// then no personal layer to keep live — the watcher is personal-only.
|
||||
const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined
|
||||
const patches = compose(
|
||||
this.options.extraOverlayPath === undefined
|
||||
? loadPersonalPatches('dsh') ?? []
|
||||
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
|
||||
)
|
||||
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
|
||||
await this.options.prepare?.(ctx)
|
||||
// Config-only HMR for the personal overlay: module reload stays off for
|
||||
// this surface (web.cordis.yml disables the shared `hmr` row until its
|
||||
// reload lifecycle is tested), so this row watches no module roots.
|
||||
if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
|
||||
})
|
||||
if (watchPersonal) {
|
||||
await watchPersonalPatches(this.ctx, { binName: 'dsh', compose })
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the diagnostic for plugin rejections that happen after settled boot. */
|
||||
@@ -324,7 +349,7 @@ export class AppCLIEntry {
|
||||
try {
|
||||
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
|
||||
} catch {
|
||||
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
|
||||
throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
|
||||
* and dynamic-imports that mode's module. One program: the default (no
|
||||
* subcommand) is the TUI/headless surface with option-only flags;
|
||||
* `experimental-meta` and `web` are real subcommands. Commander owns
|
||||
* `meta`, `upgrade`, and `web` are real subcommands; the experimental ones
|
||||
* (`meta`, `upgrade`) run only under the `--experimental` flag or
|
||||
* `DSH_EXPERIMENTAL=1`. Commander owns
|
||||
* `--help`/`--version` and parse
|
||||
* errors — it prints and exits at the point of failure (a domain failure routes through
|
||||
* `command.error`), so this returns only a resolved mode.
|
||||
@@ -46,16 +48,17 @@ interface HeadlessInvocation {
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */
|
||||
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
|
||||
interface MetaInvocation {
|
||||
mode: 'meta'
|
||||
}
|
||||
|
||||
/**
|
||||
* Guided fresh-session entry: `dsh experimental-upgrade` seeds the first turn
|
||||
* Guided fresh-session entry: `dsh upgrade` seeds the first turn
|
||||
* with the `dsh-upgrade` skill. It always mints a
|
||||
* fresh session in the invoking directory and takes no options — `--resume`,
|
||||
* `--config`, and `-p` are rejected as mistyped, so there is nothing to carry.
|
||||
* fresh session in the invoking directory and takes no options beyond the
|
||||
* experimental gate — `--resume`, `--config`, and `-p` are rejected as
|
||||
* mistyped, so there is nothing to carry.
|
||||
*/
|
||||
interface SkillSessionInvocation {
|
||||
mode: 'upgrade'
|
||||
@@ -154,9 +157,11 @@ function resolveWeb(options: WebOptions): WebInvocation {
|
||||
* TUI/headless surface; `web` is a subcommand.
|
||||
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
|
||||
* @param version - the version string `--version` prints; read from this app's package.json.
|
||||
* @param experimentalEnv - whether the environment opts into experimental
|
||||
* subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary.
|
||||
* @returns the resolved invocation (only reached on a valid, non-help invocation).
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const program = new Command()
|
||||
.name('dsh')
|
||||
@@ -248,16 +253,27 @@ Examples:
|
||||
}
|
||||
}
|
||||
|
||||
// `meta` and `upgrade` are experimental: each runs only under its own
|
||||
// `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in,
|
||||
// and fails loud otherwise so the gate is never silently skipped.
|
||||
const requireExperimental = (command: string, flag: boolean | undefined): void => {
|
||||
if (flag !== true && !experimentalEnv) {
|
||||
program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration order is the rendered help order, so daily use comes first
|
||||
// and the harness-development surfaces (`web --dev`, `experimental-meta`)
|
||||
// come last. `experimental-upgrade` is a guided fresh-session entry: it
|
||||
// takes no options and always mints a fresh session, so nothing is left to
|
||||
// carry.
|
||||
// and the harness-development surfaces (`web --dev`, `meta`)
|
||||
// come last. `upgrade` is a guided fresh-session entry: beyond the
|
||||
// experimental gate it takes no options and always mints a fresh session,
|
||||
// so nothing is left to carry.
|
||||
program
|
||||
.command('experimental-upgrade')
|
||||
.description('update this dsh installation to the latest version')
|
||||
.action(() => {
|
||||
rejectParentOptions('experimental-upgrade')
|
||||
.command('upgrade')
|
||||
.description('update this dsh installation to the latest version (experimental)')
|
||||
.option('--experimental', 'acknowledge this subcommand is experimental')
|
||||
.action((options: { experimental?: boolean }) => {
|
||||
rejectParentOptions('upgrade')
|
||||
requireExperimental('upgrade', options.experimental)
|
||||
resolved = { mode: 'upgrade' }
|
||||
})
|
||||
|
||||
@@ -269,7 +285,7 @@ Examples:
|
||||
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'developer mode: hot-reload the browser client')
|
||||
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.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')
|
||||
@@ -285,10 +301,12 @@ Examples:
|
||||
})
|
||||
|
||||
program
|
||||
.command('experimental-meta')
|
||||
.description('work on the dsh source that runs this command, from any directory')
|
||||
.action(() => {
|
||||
rejectParentOptions('experimental-meta')
|
||||
.command('meta')
|
||||
.description('work on the dsh source that runs this command, from any directory (experimental)')
|
||||
.option('--experimental', 'acknowledge this subcommand is experimental')
|
||||
.action((options: { experimental?: boolean }) => {
|
||||
rejectParentOptions('meta')
|
||||
requireExperimental('meta', options.experimental)
|
||||
resolved = { mode: 'meta' }
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ function readVersion(): string {
|
||||
}
|
||||
|
||||
loadEnv('dsh')
|
||||
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
|
||||
// The env opt-in is read at the process boundary; `1` is the documented value.
|
||||
const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1')
|
||||
|
||||
switch (invocation.mode) {
|
||||
case 'web': {
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function runHeadless(task: string): Promise<void> {
|
||||
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
|
||||
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
|
||||
dev: false,
|
||||
watchPersonalConfig: false,
|
||||
port: 0,
|
||||
})
|
||||
const { ctx, port } = await entry.run()
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* 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 experimental-meta` is the one exception — it makes this harness
|
||||
* checkout the workspace. `dsh experimental-upgrade` is a fresh session whose
|
||||
* `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.
|
||||
@@ -28,8 +28,10 @@ import {
|
||||
loadOverlayPatches,
|
||||
loadPersonalPatches,
|
||||
resolveConfigPath,
|
||||
watchPersonalPatches,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
|
||||
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
@@ -71,7 +73,7 @@ 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.
|
||||
/** The harness checkout used as the `dsh experimental-meta` workspace and source prompt path. */
|
||||
/** 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;
|
||||
@@ -88,9 +90,9 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
* {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session
|
||||
* and an overlay replacing the agent row cannot drop it.
|
||||
* @param workspace - a directory to make the workspace instead of the invoking
|
||||
* one, or `undefined` to keep the cwd. Only `dsh experimental-meta` passes it.
|
||||
* 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 `dsh experimental-upgrade` and
|
||||
* 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
|
||||
@@ -150,7 +152,7 @@ export async function runTui(
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
})
|
||||
// Resume always enters the default surface because experimental-meta rejects
|
||||
// Resume always enters the default surface because meta rejects
|
||||
// parent options, including `--resume`. The resumed session already persists
|
||||
// its cwd.
|
||||
const resumeArgs = (sessionId: string): string[] => [
|
||||
@@ -211,15 +213,16 @@ export async function runTui(
|
||||
// presence is checked against the tree actually booting, so a
|
||||
// --config-replace tree is judged on its own rows, not the shipped base's.
|
||||
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
|
||||
const patches = [
|
||||
const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [
|
||||
...replaceTree ? [] : [
|
||||
...loadOverlayPatches(NAME, TUI_OVERLAY),
|
||||
...resolvedConfig === undefined
|
||||
? loadPersonalPatches(NAME) ?? []
|
||||
? personalPatches
|
||||
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
|
||||
],
|
||||
...telemetryPatch === undefined ? [] : [telemetryPatch],
|
||||
]
|
||||
const patches = composePatches(loadPersonalPatches(NAME) ?? [])
|
||||
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
|
||||
const ctx = await boot(
|
||||
NAME,
|
||||
@@ -260,6 +263,14 @@ export async function runTui(
|
||||
}
|
||||
},
|
||||
)
|
||||
// The shipped tree includes HMR and keeps personal config live. An explicit
|
||||
// --config tree replaces the personal overlay (so there is nothing to keep
|
||||
// live), and a --config-replace or HMR-less tree remains a valid composition
|
||||
// that still receives the startup overlay but deliberately has no hidden
|
||||
// watcher.
|
||||
if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) {
|
||||
await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches })
|
||||
}
|
||||
app.current = ctx
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
if (showFirstRunWelcome) {
|
||||
|
||||
@@ -7,23 +7,87 @@
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { Context } from 'cordis'
|
||||
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
|
||||
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
|
||||
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
|
||||
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
const DSH_WEB_URL = 'DSH_WEB_URL' as const
|
||||
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
|
||||
|
||||
type WebMode = 'production' | 'development'
|
||||
|
||||
// Display-only mirror of the webserver schema's loopback host: the address the
|
||||
// local URL always prints. Not a source of truth — the schema is.
|
||||
const LOOPBACK_HOST = '127.0.0.1'
|
||||
|
||||
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
|
||||
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
|
||||
const updateContract = mode === 'development'
|
||||
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
|
||||
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
|
||||
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
|
||||
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
|
||||
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
|
||||
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
|
||||
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
|
||||
+ 'The browser provides no implicit DOM, route, or screenshot context. '
|
||||
+ updateContract
|
||||
+ 'Starting another server does not update this GUI. '
|
||||
+ 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. '
|
||||
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.'
|
||||
}
|
||||
|
||||
/** Resolve the canonical loopback URL from the active Web server. */
|
||||
function localWebUrl(ctx: Context): string {
|
||||
const port = ctx.get('httpServer')?.port
|
||||
if (port === undefined) throw new Error('dsh web: httpServer service missing while resolving Web runtime')
|
||||
return `http://${LOOPBACK_HOST}:${String(port)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the launcher-owned prompt and shell runtime context before the
|
||||
* shared config tree mounts. The earlier injections install the prompt
|
||||
* sections and managed Bash contributor when their owning services activate;
|
||||
* dynamic values read the bound server only when consumed.
|
||||
* @param ctx - Web root context with Loader installed but no config tree mounted.
|
||||
* @param sourceRoot - absolute checkout root resolved from the launcher module.
|
||||
* @param mode - whether this process mounted the client-plugin HMR receiver.
|
||||
*/
|
||||
export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
addHarnessSourceSection(promptCtx, sourceRoot)
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx), mode),
|
||||
})
|
||||
})
|
||||
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
||||
runtimeCtx.bashEnv.register({
|
||||
name: 'web-runtime',
|
||||
variables: {
|
||||
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
|
||||
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
|
||||
},
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: mode }),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
|
||||
* through only when the flag was given; absent, the shipped Web overlay value stands.
|
||||
* @param host - the bind host, or `undefined` to keep the config default.
|
||||
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
|
||||
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
|
||||
* @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles.
|
||||
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
|
||||
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
|
||||
* @param config - an overlay of loader patches applied over the shipped web
|
||||
@@ -38,17 +102,21 @@ export async function runWeb(
|
||||
trustedHosts: string[] | undefined,
|
||||
config?: string,
|
||||
): Promise<void> {
|
||||
const mode: WebMode = dev ? 'development' : 'production'
|
||||
const entry = new AppCLIEntry({
|
||||
configPath: BASE_CONFIG,
|
||||
overlayPath: WEB_OVERLAY,
|
||||
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
|
||||
dev,
|
||||
prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) },
|
||||
watchPersonalConfig: true,
|
||||
...host !== undefined && { host },
|
||||
...port !== undefined && { port },
|
||||
...workspaceRoot !== undefined && { workspaceRoot },
|
||||
...trustedHosts !== undefined && { trustedHosts },
|
||||
})
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
const resolvedLocalWebUrl = localWebUrl(ctx)
|
||||
|
||||
let exiting = false
|
||||
const shutdown = (code: number): void => {
|
||||
@@ -65,6 +133,5 @@ export async function runWeb(
|
||||
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
|
||||
// must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = entry.lanAddresses[0]
|
||||
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
|
||||
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
|
||||
console.log(`dsh web: ${resolvedLocalWebUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseDshArgs } from '../src/args.ts'
|
||||
|
||||
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
|
||||
const parse = (argv: string[], experimentalEnv = false) => parseDshArgs(argv, '1.2.3', experimentalEnv)
|
||||
|
||||
/**
|
||||
* `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets
|
||||
* Commander print to the real streams; capture the exit code and mute output.
|
||||
*/
|
||||
function exitCode(argv: string[]): number {
|
||||
function exitCode(argv: string[], experimentalEnv = false): number {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
parse(argv)
|
||||
parse(argv, experimentalEnv)
|
||||
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
|
||||
} catch {
|
||||
return exit.mock.calls.at(-1)?.[0] as number
|
||||
@@ -30,7 +30,9 @@ describe('parseDshArgs', () => {
|
||||
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' })
|
||||
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
expect(parse(['experimental-meta'])).toEqual({ mode: 'meta' })
|
||||
// Experimental subcommands run under the per-invocation flag or the env opt-in.
|
||||
expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' })
|
||||
expect(parse(['meta'], true)).toEqual({ mode: 'meta' })
|
||||
// Bare `web` carries no host/port: the shipped Web overlay owns the default.
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
|
||||
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
|
||||
@@ -39,7 +41,8 @@ describe('parseDshArgs', () => {
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
|
||||
// Guided fresh-session entries carry nothing: bare mode discriminant only.
|
||||
expect(parse(['experimental-upgrade'])).toEqual({ mode: 'upgrade' })
|
||||
expect(parse(['upgrade', '--experimental'])).toEqual({ mode: 'upgrade' })
|
||||
expect(parse(['upgrade'], true)).toEqual({ mode: 'upgrade' })
|
||||
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
@@ -64,8 +67,8 @@ describe('parseDshArgs', () => {
|
||||
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(['experimental-meta', '--dump-config'])).toBe(1)
|
||||
expect(exitCode(['experimental-upgrade', '--dump-config'])).toBe(1)
|
||||
expect(exitCode(['meta', '--experimental', '--dump-config'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--experimental', '--dump-config'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
|
||||
@@ -88,20 +91,32 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
|
||||
// Same rule for each subcommand that shares no option with the default
|
||||
// surface, so a leaked flag is a typo, not something to ignore.
|
||||
// `experimental-meta` fixes its own config tree and always starts fresh,
|
||||
// `meta` fixes its own config tree and always starts fresh,
|
||||
// so every default-surface option is rejected.
|
||||
expect(exitCode(['experimental-meta', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '-p', 'task'])).toBe(1)
|
||||
// `experimental-upgrade` takes no options: any leaked default-surface flag
|
||||
// is a mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['experimental-upgrade', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['experimental-upgrade', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'experimental-upgrade'])).toBe(1)
|
||||
expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1)
|
||||
// `upgrade` takes no options beyond the gate: any leaked default-surface
|
||||
// flag is a mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1)
|
||||
// The pre-release command names have no compatibility aliases.
|
||||
expect(exitCode(['experimental-meta'])).toBe(1)
|
||||
expect(exitCode(['experimental-upgrade'])).toBe(1)
|
||||
})
|
||||
|
||||
it('gates experimental subcommands behind --experimental or the env opt-in', () => {
|
||||
// Bare `meta`/`upgrade` without either opt-in must fail loud, not run.
|
||||
expect(exitCode(['meta'])).toBe(1)
|
||||
expect(exitCode(['upgrade'])).toBe(1)
|
||||
// A leaked default-surface flag stays a typo even when the gate is passed
|
||||
// by the environment alone.
|
||||
expect(exitCode(['meta', '--resume', 's'], true)).toBe(1)
|
||||
// The flag and the env opt-in may coexist.
|
||||
expect(parse(['meta', '--experimental'], true)).toEqual({ mode: 'meta' })
|
||||
expect(parse(['upgrade', '--experimental'], true)).toEqual({ mode: 'upgrade' })
|
||||
})
|
||||
|
||||
it('exits 0 for --help (disclosing web) and --version', () => {
|
||||
|
||||
@@ -54,6 +54,31 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
describe('experimental subcommand gate', () => {
|
||||
// The gate has two halves: a per-invocation --experimental flag parsed by
|
||||
// Commander and an env opt-in read by bin.ts as exactly '1'. Passing the
|
||||
// gate is proven by reaching the NEXT failure — the TUI's piped-stdio
|
||||
// refusal — instead of the gate diagnostic.
|
||||
it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => {
|
||||
for (const command of ['meta', 'upgrade']) {
|
||||
const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' })
|
||||
expect(code).toBe(1)
|
||||
expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => {
|
||||
const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' })
|
||||
expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
||||
const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' })
|
||||
expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
||||
// The env opt-in is exact: '0' (or any other value) does not enable.
|
||||
const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' })
|
||||
expect(zero.code).toBe(1)
|
||||
expect(zero.stderr).toContain('meta is experimental')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe('dsh --dump-config', () => {
|
||||
let home: string
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
|
||||
@@ -69,6 +94,13 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain('model: deepseek-v4-pro')
|
||||
expect(stdout).toContain('cwd: !!js process.cwd()')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
|
||||
expect(stdout).toContain([
|
||||
'- id: tool-web',
|
||||
" name: '@deepseek-ai/dsh-tool-web'",
|
||||
' config:',
|
||||
' fetch: false',
|
||||
' searchTimeoutMs: 60000',
|
||||
].join('\n'))
|
||||
// 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')
|
||||
|
||||
155
apps/cli/tests/install-script.spec.ts
Normal file
155
apps/cli/tests/install-script.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
|
||||
const fixtures: string[] = []
|
||||
|
||||
const PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
script, cwd, env_json, actions_json = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(env_json))
|
||||
actions = json.loads(actions_json)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe("sh", ["sh", script], env)
|
||||
|
||||
output = bytearray()
|
||||
action_index = 0
|
||||
deadline = time.monotonic() + 15
|
||||
status = None
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if ready:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EIO:
|
||||
raise
|
||||
chunk = b""
|
||||
output.extend(chunk)
|
||||
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
|
||||
os.write(fd, actions[action_index]["send"].encode())
|
||||
action_index += 1
|
||||
waited, candidate = os.waitpid(pid, os.WNOHANG)
|
||||
if waited == pid:
|
||||
status = candidate
|
||||
break
|
||||
|
||||
if status is None:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
sys.stdout.buffer.write(output)
|
||||
if action_index != len(actions):
|
||||
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
|
||||
sys.exit(124)
|
||||
sys.exit(os.waitstatus_to_exitcode(status))
|
||||
`
|
||||
|
||||
interface Action {
|
||||
readonly waitFor: string
|
||||
readonly send: string
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly binDirectory: string
|
||||
readonly launchLog: string
|
||||
readonly pnpmLog: string
|
||||
readonly root: string
|
||||
readonly script: string
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
|
||||
})
|
||||
|
||||
function executable(path: string, content: string): void {
|
||||
writeFileSync(path, content)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
|
||||
async function createFixture(): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
|
||||
fixtures.push(root)
|
||||
const checkoutDirectory = join(root, 'checkout')
|
||||
const scriptsDirectory = join(checkoutDirectory, 'scripts')
|
||||
const sourceBinDirectory = join(checkoutDirectory, 'bin')
|
||||
const fakeBinDirectory = join(root, 'fake-bin')
|
||||
const binDirectory = join(root, 'path-bin')
|
||||
for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
}
|
||||
const script = join(scriptsDirectory, 'install.sh')
|
||||
copyFileSync(installer, script)
|
||||
const launchLog = join(root, 'launch.log')
|
||||
const pnpmLog = join(root, 'pnpm.log')
|
||||
executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
|
||||
executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
|
||||
if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
|
||||
printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
|
||||
`)
|
||||
await execa('git', ['init', '-q'], { cwd: checkoutDirectory })
|
||||
await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory })
|
||||
await execa('git', [
|
||||
'-c', 'user.name=dsh-test',
|
||||
'-c', 'user.email=dsh-test@example.invalid',
|
||||
'commit', '-qm', 'fixture',
|
||||
], { cwd: checkoutDirectory })
|
||||
writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
|
||||
return { binDirectory, launchLog, pnpmLog, root, script }
|
||||
}
|
||||
|
||||
async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
|
||||
const result = await execa('python3', [
|
||||
'-c',
|
||||
PTY_DRIVER,
|
||||
fixture.script,
|
||||
fixture.root,
|
||||
JSON.stringify({
|
||||
DSH_BIN_DIR: fixture.binDirectory,
|
||||
DSH_HOME: join(fixture.root, 'home/.dsh'),
|
||||
DSH_TEST_LAUNCH_LOG: fixture.launchLog,
|
||||
DSH_TEST_PNPM_LOG: fixture.pnpmLog,
|
||||
HOME: join(fixture.root, 'home'),
|
||||
PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
|
||||
}),
|
||||
JSON.stringify(actions),
|
||||
], { reject: false, stripFinalNewline: false, timeout: 20_000 })
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('one-line installer interface choice', { timeout: 25_000 }, () => {
|
||||
it('builds and launches the Web UI when the default choice is accepted', async () => {
|
||||
const fixture = await createFixture()
|
||||
|
||||
const output = await runInstaller(fixture, [
|
||||
{ waitFor: 'Replace it?', send: '\n' },
|
||||
{ waitFor: 'Choose an interface [1/2]:', send: '\n' },
|
||||
])
|
||||
|
||||
expect(output).toContain('launching Web UI')
|
||||
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
|
||||
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
|
||||
})
|
||||
|
||||
it('rejects an unknown choice, then launches the TUI without building', async () => {
|
||||
const fixture = await createFixture()
|
||||
|
||||
const output = await runInstaller(fixture, [
|
||||
{ waitFor: 'Replace it?', send: '\n' },
|
||||
{ waitFor: 'Choose an interface [1/2]:', send: 'terminal\n' },
|
||||
{ waitFor: 'choose 1 for Web UI or 2 for TUI', send: '2\n' },
|
||||
])
|
||||
|
||||
expect(output).toContain('launching TUI')
|
||||
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n')
|
||||
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('\n')
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-r
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const PERMISSION_SUMMARY = 'current preset workspace-write (available: read-only, workspace-write, danger-full-access)'
|
||||
// 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.
|
||||
@@ -32,13 +33,10 @@ const EXPECTED_TUI_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
'session_event_read',
|
||||
'session_event_search',
|
||||
'session_event_trace',
|
||||
'session_search',
|
||||
'session_trace',
|
||||
'send_message',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
@@ -67,6 +65,8 @@ interface LoggedHeader {
|
||||
names: string[]
|
||||
/** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */
|
||||
bashArguments: Record<string, unknown>
|
||||
/** Initial permission facts pinned by the shipped composition. */
|
||||
permissionEvents: Array<[string, unknown]>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,18 +82,22 @@ async function loggedHeader(cwd: string): Promise<LoggedHeader> {
|
||||
// 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 ?? {},
|
||||
}
|
||||
const events = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
|
||||
.map(line => JSON.parse(line) as SessionEvent)
|
||||
const header = events.find(event => event.type === 'request/header')
|
||||
if (header === undefined || header.type !== 'request/header') {
|
||||
throw new Error(`session log ${logRelPath} has no request/header event`)
|
||||
}
|
||||
const tools = header.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 ?? {},
|
||||
permissionEvents: events.flatMap(event =>
|
||||
event.type === 'permission/preset' || event.type === 'sandbox/mode' || event.type === 'approval/policy'
|
||||
? [[event.type, event.data] as [string, unknown]]
|
||||
: []),
|
||||
}
|
||||
throw new Error(`session log ${logRelPath} has no request/header event`)
|
||||
}
|
||||
|
||||
describe('shipped dsh composition (real Loader tree in a PTY)', () => {
|
||||
@@ -110,17 +114,22 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => {
|
||||
// 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_SETTLED_MARKER, send: '/permission\r' },
|
||||
{ waitFor: PERMISSION_SUMMARY, 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(output).toContain(PERMISSION_SUMMARY)
|
||||
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')
|
||||
expect(observed?.bashArguments).toHaveProperty('sandbox_permissions')
|
||||
expect(observed?.bashArguments).toHaveProperty('justification')
|
||||
expect(observed?.permissionEvents).toEqual([
|
||||
['permission/preset', { preset: 'workspace-write' }],
|
||||
['sandbox/mode', { mode: 'workspace-write' }],
|
||||
['approval/policy', { policy: 'ask' }],
|
||||
])
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{"type":"session","version":0,"id":"main-session","createdAt":1784606400000,"cwd":"{{cwd}}"}
|
||||
{"type":"turn/start","seq":0,"time":1784606400000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784606400000,"data":{"content":[{"type":"text","text":"what's the workdir?"}],"source":{"kind":"user"},"role":"user","id":"3fdc2885-1bea-4c6c-b4af-dbd5af7594f8"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784606400000,"data":{"title":"what's the workdir?","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784606400000,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784606400000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1784606400000,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," working"," directory","."," Let"," me"," check"," using"," p","wd","."]}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"tool-call-chunks","seq0":24,"time0":1784606400000,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd","\"",", ","\"","description","\"",": ","\"","Print"," current"," working"," directory","\"","}"]}}
|
||||
{"type":"assistant/chunk","seq":46,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."}}}}
|
||||
{"type":"assistant/chunk","seq":47,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":48,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":50,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."},{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"063a9245-32c3-4551-9ace-d43f10ed5582"},"usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":51,"time":1784606400000,"data":{"turn":1,"step":1,"callId":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}
|
||||
{"type":"tool/result","seq":52,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_AqoWTncquNel5ZHsJHOo7491"},"content":[{"type":"tool-result","toolCallId":"call_00_AqoWTncquNel5ZHsJHOo7491","content":[{"type":"text","text":"{{cwd}}\n"}],"isError":false}],"role":"user","id":"16086d3b-6dfa-4970-a06e-78561475af8c"}},"sourceEventSeqs":[51],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":53,"time":1784606400000,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":54,"time":1784606400000,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":55,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":56,"time0":1784606400000,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is"," `/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","`."]}}
|
||||
{"type":"assistant/chunk","seq":83,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"text-chunks","seq0":84,"time0":1784606400000,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is",":\n\n","```\n","/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","\n","```\n\n","Note"," that"," the"," Deep","Se","ek"," Har","ness"," SDK"," checkout"," is"," at"," `/","opt","/d","sh","-source","`"," —"," that","'s"," a"," separate"," location"," used"," for"," inspecting"," or"," extending"," D","SH"," itself",","," while"," the"," working"," directory"," above"," is"," the"," current"," working"," directory"," for"," this"," session","."]}}
|
||||
{"type":"assistant/chunk","seq":161,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current working directory is `{{cwd}}`."}}}}
|
||||
{"type":"assistant/chunk","seq":162,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}}}}
|
||||
{"type":"assistant/chunk","seq":163,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}}}}
|
||||
{"type":"assistant/chunk","seq":164,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":165,"time":1784606400000,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current working directory is `{{cwd}}`."},{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"76c18b85-00aa-45f6-a32f-a2bd75412470"},"usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}},"sourceEventSeqs":[55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":166,"time":1784606400000,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":167,"time":1784606400000,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,67 @@
|
||||
terminal 100x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "what's the workdir? — DSH TUI snapshot"
|
||||
cursor hidden column=7 viewportRow=32 bufferRow=32
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " what's the workdir?"
|
||||
style 1-19 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
5| "what's the workdir? "
|
||||
6| <blank>
|
||||
7| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
8| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
9| "The user is asking about the current working directory. Let me check using pwd. "
|
||||
style 0-78 dim italic
|
||||
10| <blank>
|
||||
11| "● Tool / bash / Print current working directory"
|
||||
style 0-46 fg=green
|
||||
12| "$ pwd "
|
||||
style 0-4 dim
|
||||
13| "/workspace/project "
|
||||
style 0-17 dim
|
||||
14| "[exit 0] "
|
||||
style 0-7 dim
|
||||
15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
16| <blank>
|
||||
17| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
18| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
19| "The current working directory is /workspace/project. "
|
||||
style 0-32 dim italic
|
||||
style 33-84 fg=cyan
|
||||
style 85-85 dim italic
|
||||
20| "The current working directory is: "
|
||||
21| " "
|
||||
22| " "
|
||||
23| " /workspace/project "
|
||||
style 2-53 fg=cyan
|
||||
24| " "
|
||||
25| " "
|
||||
26| "Note that the DeepSeek Harness SDK checkout is at /opt/dsh-source — that's a separate location used "
|
||||
style 50-64 fg=cyan
|
||||
27| "for inspecting or extending DSH itself, while the working directory above is the current working "
|
||||
28| "directory for this session. "
|
||||
29| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
30| <blank>
|
||||
31| "/workspace/project deepseek-v4-flash ↑3.4k ↓185 cache 49% 3% c"
|
||||
style 0-51 fg=bright-magenta bold
|
||||
style 54-70 dim
|
||||
style 73-93 dim
|
||||
style 96-99 dim
|
||||
32| " dsh ◍ "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
33-35| <blank>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -6,6 +7,7 @@ import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
|
||||
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
|
||||
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
|
||||
@@ -69,6 +71,38 @@ function seedWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin`
|
||||
* package and return the generated wrapper text, so the smoke's cache-seeded
|
||||
* wrapper can never drift from the generator's template.
|
||||
*/
|
||||
async function generatePreparedWrapper(pluginName: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-'))
|
||||
try {
|
||||
const plugin = join(root, '.dsh-plugin')
|
||||
await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true })
|
||||
await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [
|
||||
'---',
|
||||
'name: config-only-repository',
|
||||
'description: Generator input; the seeded cache copy owns the visible text.',
|
||||
'---',
|
||||
'',
|
||||
'Repository instructions.',
|
||||
'',
|
||||
].join('\n'))
|
||||
await mkdir(plugin, { recursive: true })
|
||||
await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
|
||||
name: pluginName,
|
||||
version: '0.0.0',
|
||||
dsh: { skills: ['../skills'] },
|
||||
}, undefined, 2)}\n`)
|
||||
await prepareDshPlugin(plugin)
|
||||
return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
|
||||
async function seedResumeSession(cwd: string): Promise<void> {
|
||||
const sessionCwd = realpathSync.native(cwd)
|
||||
@@ -662,6 +696,54 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('loads a cached repository Plugin from personal config alone', async () => {
|
||||
const source = 'github:fixture/repository#fixed-ref'
|
||||
const specifier = `${source}&path:/.dsh-plugin`
|
||||
const key = createHash('sha256').update(specifier).digest('hex')
|
||||
const packageRoot = `cache/repository-plugins/${key}/node_modules/repository`
|
||||
// Produced by the real generator (prepareDshPlugin over an equivalent
|
||||
// .dsh-plugin package) rather than hand-written, so a wrapper-template
|
||||
// change cannot leave this smoke exercising a stale shape. The cache
|
||||
// LAYOUT below (sha256 key, marker, node_modules/repository) remains a
|
||||
// deliberate external pin of the durable on-disk format.
|
||||
const wrapper = await generatePreparedWrapper('config-only-fixture')
|
||||
const output = await smoke({
|
||||
label: 'dsh personal repository Plugin',
|
||||
tempDirPrefix: 'dsh-personal-repository-plugin-',
|
||||
binScript: dshBinScript,
|
||||
configArgs: [],
|
||||
prepare: seedWorkspace({
|
||||
personal: {
|
||||
'config.yaml': [
|
||||
'- id: repository-plugins',
|
||||
" name: '@deepseek-ai/dsh-repository-plugin'",
|
||||
' config:',
|
||||
' repositories:',
|
||||
` - '${source}'`,
|
||||
'',
|
||||
].join('\n'),
|
||||
[`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`,
|
||||
[`${packageRoot}/dsh-plugin.mjs`]: wrapper,
|
||||
[`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [
|
||||
'---',
|
||||
'name: config-only-repository',
|
||||
'description: CONFIG_ONLY_REPOSITORY_SKILL',
|
||||
'---',
|
||||
'',
|
||||
'Repository instructions.',
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
}),
|
||||
actions: [
|
||||
{ waitFor: 'main-session-', send: '/skill:config-only' },
|
||||
{ waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' },
|
||||
],
|
||||
})
|
||||
expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh invalid personal config',
|
||||
@@ -760,11 +842,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).not.toContain('[exit code: 3]')
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('tells the model its source path and offers the bundled maintenance skills', async () => {
|
||||
it('distinguishes its source path from the current workdir and offers the bundled maintenance skills', async () => {
|
||||
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
|
||||
// this test file sits an equal depth under the same root, so the same hop applies.
|
||||
// The source-path line is a system-prompt section; the bundled skills reach the
|
||||
// model through a durable user message, so each assertion targets its own field.
|
||||
// The source-path line explicitly distinguishes that checkout from the current workdir;
|
||||
// bundled skills reach the model through a durable user message, so each assertion
|
||||
// targets its own field.
|
||||
const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
let context: LoggedRequestContext = { system: '', skillCatalog: '' }
|
||||
await smoke({
|
||||
@@ -780,7 +863,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
],
|
||||
inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) },
|
||||
})
|
||||
expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`)
|
||||
expect(context.system).toContain(`The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
|
||||
expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.")
|
||||
expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.')
|
||||
expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.')
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
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'
|
||||
@@ -59,6 +60,10 @@ interface Scenario {
|
||||
leavePlanModeAfterFirstTurn?: boolean
|
||||
recorded: boolean
|
||||
seedWorkspace?: boolean
|
||||
/** Add the launcher's model-visible DSH source checkout at this fixed path. */
|
||||
harnessSourceRoot?: string
|
||||
/** Replace the real `pwd` result with a portable fixed-length workspace path. */
|
||||
normalizePwdResult?: boolean
|
||||
/**
|
||||
* Load the opt-in `todo_write` tool for this scenario. The shipped TUI
|
||||
* config omits it, so only the todo-plan scenario (the enabled-path proof)
|
||||
@@ -115,6 +120,14 @@ const SCENARIOS: Scenario[] = [
|
||||
expectedTools: ['bash'],
|
||||
recorded: true,
|
||||
},
|
||||
{
|
||||
name: 'source-checkout-workdir',
|
||||
composition: 'native',
|
||||
expectedTools: ['bash'],
|
||||
recorded: true,
|
||||
harnessSourceRoot: '/opt/dsh-source',
|
||||
normalizePwdResult: true,
|
||||
},
|
||||
{
|
||||
name: 'parallel-file-reads',
|
||||
composition: 'native',
|
||||
@@ -251,6 +264,12 @@ function rawSessionLog(session: Session): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function materializeFixtureCwd(fixtureFile: string, cwd: string, replayRoot: string): Promise<string> {
|
||||
const realized = join(replayRoot, basename(fixtureFile))
|
||||
await writeFile(realized, (await readFile(fixtureFile, 'utf8')).split('{{cwd}}').join(cwd))
|
||||
return realized
|
||||
}
|
||||
|
||||
function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
|
||||
return snapshot
|
||||
.split(`/private${cwd}`).join('/workspace/project')
|
||||
@@ -294,6 +313,7 @@ async function mountScenarioContext(
|
||||
displayCwd: string,
|
||||
fixtureFile: string,
|
||||
childFiles: string[],
|
||||
replayRoot: string | undefined,
|
||||
): Promise<Context> {
|
||||
class SnapshotLocalFileSystem extends LocalFileSystem {
|
||||
override async resolve(
|
||||
@@ -313,6 +333,7 @@ async function mountScenarioContext(
|
||||
tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' },
|
||||
skills: { local: { agentsHome: join(cwd, '.agents') } },
|
||||
})
|
||||
if (scenario.harnessSourceRoot !== undefined) addHarnessSourceSection(ctx, scenario.harnessSourceRoot)
|
||||
await ctx.plugin(TokenMeterService)
|
||||
if (scenario.manualCompact === true) {
|
||||
await ctx.plugin(DeferredSnapshotCompactService, { auto: false })
|
||||
@@ -349,7 +370,12 @@ async function mountScenarioContext(
|
||||
if (MODE === 'record' && scenario.recorded) {
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
} else {
|
||||
installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS })
|
||||
if (replayRoot === undefined) throw new Error('replay mode requires an isolated fixture directory')
|
||||
// Recorded model text may name the generated cwd. Realize the portable token
|
||||
// outside that cwd so tools see only the scenario workspace during replay.
|
||||
const replayFile = await materializeFixtureCwd(fixtureFile, cwd, replayRoot)
|
||||
const replayChildFiles = await Promise.all(childFiles.map(file => materializeFixtureCwd(file, cwd, replayRoot)))
|
||||
installLlmReplay(ctx, { file: replayFile, childFiles: replayChildFiles, providers: PROVIDERS })
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -373,15 +399,27 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
|
||||
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
|
||||
const displayCwd = `/tmp/${basename(cwd)}`
|
||||
let replayRoot: string | undefined
|
||||
let ctx: Context | undefined
|
||||
let controller: ReturnType<typeof createTuiChat> | undefined
|
||||
const terminal = new HeadlessTerminal(100, 36)
|
||||
try {
|
||||
if (!(MODE === 'record' && scenario.recorded)) {
|
||||
replayRoot = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-replay-${scenario.name}-`))
|
||||
}
|
||||
if (scenario.seedWorkspace === true) {
|
||||
const source = join(fixtureDir(scenario), 'workspace')
|
||||
await cp(source, cwd, { recursive: true })
|
||||
}
|
||||
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
|
||||
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot)
|
||||
if (scenario.normalizePwdResult === true) {
|
||||
ctx.on('tools/post-execute', async (exec, result, next) => {
|
||||
const args = exec.arguments as { command?: unknown }
|
||||
return exec.name === 'bash' && args.command === 'pwd' && !result.isError
|
||||
? { kind: 'accept', content: [{ type: 'text', text: '/workspace/project\n' }] }
|
||||
: next()
|
||||
})
|
||||
}
|
||||
const disposedSessions: Session[] = []
|
||||
ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
|
||||
const workflowEvents: string[] = []
|
||||
@@ -582,6 +620,10 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const firstHeader = events.find(event => event.type === 'request/header')
|
||||
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
|
||||
.toContain(FILE_REFERENCE_PROMPT)
|
||||
if (scenario.harnessSourceRoot !== undefined) {
|
||||
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
|
||||
.toContain(`The DeepSeek Harness implementation checkout is at ${scenario.harnessSourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
|
||||
}
|
||||
expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools)
|
||||
for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
|
||||
expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
|
||||
@@ -734,6 +776,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
await ctx?.fiber.dispose()
|
||||
await terminal.dispose()
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
|
||||
clock.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
32
apps/cli/tests/web-prompt-context.spec.ts
Normal file
32
apps/cli/tests/web-prompt-context.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { sep } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { prepareWebRuntimeContext } from '../src/web.ts'
|
||||
|
||||
describe('prepareWebRuntimeContext', () => {
|
||||
it('installs both sections before a later systemPrompt consumer activates', async () => {
|
||||
const ctx = new Context()
|
||||
const sourceRoot = `${sep}opt${sep}harness-src`
|
||||
let observedSections: { name: string; text: string }[] | undefined
|
||||
try {
|
||||
prepareWebRuntimeContext(ctx, sourceRoot, 'production')
|
||||
ctx.provide('httpServer', { port: 3080 } as Context['httpServer'])
|
||||
const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => {
|
||||
const assembly = await promptCtx.systemPrompt.assemble()
|
||||
observedSections = assembly.sections
|
||||
})
|
||||
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
|
||||
await consumer
|
||||
|
||||
expect(observedSections?.map(section => section.name)).toContain(HARNESS_SOURCE_SECTION)
|
||||
expect(observedSections?.find(section => section.name === 'app:web-surface')?.text)
|
||||
.toContain('http://127.0.0.1:3080')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../packages/ui/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/bash/tool-bash"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/ui/tui"
|
||||
},
|
||||
|
||||
@@ -70,14 +70,7 @@ describe('web e2e: Full access confirmation', () => {
|
||||
const access = page.locator('button[aria-label^="访问模式"]').first()
|
||||
await access.waitFor({ timeout: 10_000 })
|
||||
|
||||
// Normalize the starting preset through the real command path. The
|
||||
// shipped web config may already start at Full access.
|
||||
if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('访问模式,当前:Workspace Write')
|
||||
}
|
||||
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write')
|
||||
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
|
||||
@@ -108,7 +108,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
// Opening a session reaches chat content through the fixture transport.
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
// the bash sub-call landed in the bash sample registration.
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
await nest.waitFor({ timeout: 10_000 })
|
||||
expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1)
|
||||
expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1)
|
||||
// The failing read sub-call wears the same error state a native failed
|
||||
// row wears (the recorded program tolerates a read of missing.txt).
|
||||
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
|
||||
@@ -123,7 +123,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
await nest.locator('[data-sample="bash-global"]').first().click()
|
||||
await nest.locator('[data-sample="bash"]').first().click()
|
||||
// Tool rows do not drive layout geometry; the Session's default panel stays closed.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
})
|
||||
|
||||
71
apps/web/tests/goal-bar.e2e.ts
Normal file
71
apps/web/tests/goal-bar.e2e.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Keyless assembled-browser coverage for the goal bar over the shipped Web
|
||||
// bundles and FixtureApiClient wire. The command creates a real projected
|
||||
// goal in the fixture session; the golden pins the active strip, while the
|
||||
// clear gesture proves the acknowledged tombstone leaves neither stale chrome
|
||||
// nor a duplicate-mutation error.
|
||||
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, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-bar', import.meta.url))
|
||||
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
|
||||
const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: goal bar clear convergence', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders one active goal and clears it without exposing a stale error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
|
||||
// Startup reuses the fixture workspace's blank session, keeping this
|
||||
// command independent of alpha's running replay and pending question.
|
||||
const input = page.getByPlaceholder('Describe what you want to build')
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await input.fill('/goal guard rapid clear clicks')
|
||||
await input.press('Enter')
|
||||
|
||||
const bar = page.locator('[data-goal-bar]')
|
||||
await bar.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
|
||||
|
||||
const clear = bar.getByRole('button', { name: 'Clear goal' })
|
||||
await clear.evaluate((button) => {
|
||||
const control = button as HTMLButtonElement
|
||||
control.click()
|
||||
control.click()
|
||||
})
|
||||
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await page.getByText(/no current goal/iu).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md'])
|
||||
})
|
||||
})
|
||||
5
apps/web/tests/goal-bar.overlay.yml
Normal file
5
apps/web/tests/goal-bar.overlay.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
# The client-side FixtureApiClient intentionally rejects settings writes, so
|
||||
# this goal-only scenario omits the durable welcome step that would otherwise
|
||||
# cover the page. Onboarding owns separate assembled-browser coverage.
|
||||
- id: ui-settings-general
|
||||
disabled: true
|
||||
132
apps/web/tests/hmr-live.e2e.ts
Normal file
132
apps/web/tests/hmr-live.e2e.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Fiber } from 'cordis'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { REPO_ROOT } from './support.ts'
|
||||
|
||||
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
|
||||
return {
|
||||
argv,
|
||||
cwd,
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
||||
graceMs: 5_000,
|
||||
...env === undefined ? {} : { env },
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let output = ''
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
child.stdout?.off('data', onData)
|
||||
child.stderr?.off('data', onData)
|
||||
}
|
||||
const resolveOnce = (value: string): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolveReady(value)
|
||||
}
|
||||
const rejectOnce = (error: Error): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk: Buffer): void => {
|
||||
output += chunk.toString()
|
||||
const match = pattern.exec(output)
|
||||
if (match === null) return
|
||||
resolveOnce(match[1] ?? match[0])
|
||||
}
|
||||
const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onData)
|
||||
void child.done.then((outcome) => {
|
||||
rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
|
||||
}, (error: unknown) => {
|
||||
rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function stopTree(child: SubprocessHandle): Promise<void> {
|
||||
child.terminate()
|
||||
const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
|
||||
if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
|
||||
await child.done
|
||||
}
|
||||
|
||||
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
|
||||
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
|
||||
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
|
||||
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
|
||||
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
|
||||
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
|
||||
const originalSource = await readFile(sourcePath)
|
||||
const originalBundle = await readFile(bundlePath)
|
||||
const oldText = "Let's start building"
|
||||
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
|
||||
const newText = `HMR UPDATED ${'x'.repeat(80)}`
|
||||
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
|
||||
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
|
||||
|
||||
const subprocessCtx = new Context()
|
||||
let subprocessFiber: Fiber | undefined
|
||||
let watcher: SubprocessHandle | undefined
|
||||
let host: SubprocessHandle | undefined
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
|
||||
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
|
||||
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
|
||||
host = subprocessCtx.subprocess.spawn(spawnSpec(
|
||||
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
|
||||
world,
|
||||
{
|
||||
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
|
||||
DSH_HOME: join(world, '.dsh'),
|
||||
},
|
||||
))
|
||||
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', error => pageErrors.push(String(error)))
|
||||
await page.goto(baseUrl, { waitUntil: 'load' })
|
||||
await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
const pageIdentity = await page.evaluate(() => {
|
||||
const identity = crypto.randomUUID()
|
||||
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
|
||||
return identity
|
||||
})
|
||||
|
||||
await writeFile(sourcePath, updatedSource)
|
||||
await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
|
||||
.toBe(pageIdentity)
|
||||
expect(pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
} finally {
|
||||
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
|
||||
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
|
||||
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
|
||||
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
|
||||
}, 120_000)
|
||||
@@ -158,8 +158,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
}
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const observeTurn = async () => {
|
||||
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
|
||||
try {
|
||||
await input.press('Enter')
|
||||
if (MODE !== 'record') {
|
||||
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
|
||||
await expect.poll(async () => await liveTail.evaluate(element => (
|
||||
element.scrollWidth > element.clientWidth
|
||||
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
|
||||
)), { timeout: 10_000, interval: 10 }).toBe(true)
|
||||
}
|
||||
return await settled
|
||||
} finally {
|
||||
if (MODE !== 'record') await page.setViewportSize(originalViewport)
|
||||
}
|
||||
}
|
||||
const sessionId = await observeTurn()
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The frame mounts before the asynchronous session-list baseline lands.
|
||||
// Search must target the settled seeded row, not the startup input that
|
||||
// the ready projection replaces.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -184,7 +188,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
@@ -194,7 +198,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
// The card's own controls are outside the summary row and must not open
|
||||
// details either — the expanded terminal card is read in place.
|
||||
await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
// Read summaries are host-open file links; they also must not open details.
|
||||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||||
@@ -210,10 +214,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
// tool-row interaction): open it if a previous case left it collapsed.
|
||||
// Expanded, the recorded command's own output sits in the message flow,
|
||||
// derived from the logged call/result presentations alone.
|
||||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
|
||||
const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first()
|
||||
const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
|
||||
await card.waitFor({ timeout: 15_000 })
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
|
||||
@@ -22,6 +22,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
|
||||
const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
|
||||
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
|
||||
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -172,10 +173,95 @@ describe('web e2e: queue row actions', () => {
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
|
||||
const readyFile = join(overrideDir, '.hang-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
|
||||
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill('/goal Keep the composer context panels aligned')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
|
||||
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
|
||||
|
||||
const sessions = scaffold.ctx.sessions.list()
|
||||
expect(sessions).toHaveLength(1)
|
||||
sessions[0]!.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'Confirm the panel order', status: 'completed' },
|
||||
{ content: 'Align the panel widths', status: 'in_progress' },
|
||||
],
|
||||
})
|
||||
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
|
||||
|
||||
for (const text of ['Layout queue first', 'Layout queue second']) {
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
}
|
||||
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
|
||||
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
|
||||
.toBe('false')
|
||||
|
||||
const layoutSnapshot = await captureStableAria(
|
||||
page,
|
||||
'[class*="centerCol"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
|
||||
|
||||
const expectAlignedContextPanels = async () => {
|
||||
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
|
||||
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
|
||||
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
|
||||
expect(queuePanelBox).not.toBeNull()
|
||||
expect(todoBox).not.toBeNull()
|
||||
expect(goalBox).not.toBeNull()
|
||||
expect(todoBox!.y).toBeLessThan(goalBox!.y)
|
||||
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
|
||||
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
|
||||
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
|
||||
}
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 640, height: 1000 })
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
await queueHeader.click()
|
||||
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Clear goal' }).click()
|
||||
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
|
||||
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['collapsed.expected.md', 'editing.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,20 +9,23 @@
|
||||
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
|
||||
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
|
||||
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionId } 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'
|
||||
import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
|
||||
const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The scenario's one drive prompt. Record sends it; replay asserts the
|
||||
@@ -35,6 +38,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let settledSessionId: SessionId | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -69,11 +73,44 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
settledSessionId = sessionId
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 200_000)
|
||||
|
||||
it('records the Web surface, source checkout, and session cwd in the request header', async () => {
|
||||
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
|
||||
const agent = scaffold.ctx.agents.get(settledSessionId)
|
||||
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
|
||||
const system = agent.session.requestHeader()?.system
|
||||
if (system === undefined) throw new Error('the settled Web request has no system prompt')
|
||||
const prefix = system.split('\n\n').slice(0, 4).join('\n\n')
|
||||
.split(REPO_ROOT).join('{{sourceRoot}}')
|
||||
.split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}')
|
||||
.split(scaffold.baseUrl).join('{{webUrl}}')
|
||||
await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE)
|
||||
})
|
||||
|
||||
it('exposes the assembled Web URL to the real bash tool', async () => {
|
||||
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
|
||||
const agent = scaffold.ctx.agents.get(settledSessionId)
|
||||
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
|
||||
const result = await scaffold.ctx.tools.execute({
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
callId: CallId('web-url-probe'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
|
||||
description: 'Print current Web runtime',
|
||||
},
|
||||
agent,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toBe(`${scaffold.baseUrl}\nproduction\n`)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
|
||||
// Browser settled-poll after host completion (host strictly precedes render).
|
||||
@@ -129,6 +166,6 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
|
||||
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
|
||||
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
|
||||
@@ -120,6 +121,11 @@ export interface LaunchOptions {
|
||||
* mounts).
|
||||
*/
|
||||
replayFixture?: string
|
||||
/**
|
||||
* Recorded child logs assigned in child creation order. Each child owns its
|
||||
* own positional replay cursor across initial and continuation turns.
|
||||
*/
|
||||
replayChildFixtures?: string[]
|
||||
/**
|
||||
* Optional replay.override.json sidecar (whole-script replacement or
|
||||
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
|
||||
@@ -300,6 +306,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// The shipped CLI deliberately has no dependency on this opt-in package.
|
||||
// Keep the Loader row real without broadening the product installation.
|
||||
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
|
||||
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
|
||||
@@ -326,6 +333,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
file: options.replayFixture,
|
||||
providers: REPLAY_PROVIDERS,
|
||||
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('assembled search card', () => {
|
||||
// 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()
|
||||
expect(document.querySelector('[data-sample="bash"]')).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.
|
||||
|
||||
@@ -339,19 +339,19 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
|
||||
// The Access chip submits `/permission <preset>` — a host command with no
|
||||
// model call, so the settled row renders keylessly over this cold history.
|
||||
// The row copy is the assertion: `permission · preset workspace-write`,
|
||||
// The row copy is the assertion: `permission · preset read-only`,
|
||||
// where neither half repeats the other (the dispatched `/` and its
|
||||
// argument stay out of the title, and the settlement text never restates
|
||||
// the command's own name).
|
||||
await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
|
||||
// Scoped to the row itself, so unrelated page text that happens to read
|
||||
// `permission` (a future resident slash menu) cannot satisfy or break it.
|
||||
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' })
|
||||
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
|
||||
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
|
||||
expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0)
|
||||
expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// section switching, both close paths), the Appearance preference row (the
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
|
||||
// and the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// plus Permission as the persisted default for subsequently created sessions.
|
||||
// the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// the busy-state Enter preference, plus Permission as the persisted default
|
||||
// for subsequently created sessions.
|
||||
// Zero model calls: everything is pure client + persistence state on a blank
|
||||
// frame, so there is no fixture and a stray stream would fail loud on the
|
||||
// open llm seam.
|
||||
@@ -57,7 +58,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
// Golden of the freshly opened dialog (default zh, General active).
|
||||
@@ -82,12 +83,12 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
|
||||
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const selector = dialog.getByRole('button', { name: 'Full access' })
|
||||
const selector = dialog.getByRole('button', { name: 'Workspace Write' })
|
||||
await selector.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await selector.click()
|
||||
@@ -98,7 +99,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(document).toContain('permission:')
|
||||
expect(document).toContain('defaultPreset: read-only')
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
|
||||
expect(created.events.map(event => [event.type, event.data])).toEqual([
|
||||
@@ -182,6 +183,32 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('persists the busy-state Enter behavior across reload and restores Queue', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '排队发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '插话发送' }).click()
|
||||
await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '排队发送' }).click()
|
||||
await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue')
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('switches the settings surface language and persists dsh.locale', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
|
||||
@@ -10,6 +10,7 @@ import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
/**
|
||||
@@ -27,13 +28,10 @@ const EXPECTED_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
'session_event_read',
|
||||
'session_event_search',
|
||||
'session_event_trace',
|
||||
'session_search',
|
||||
'session_trace',
|
||||
'send_message',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
@@ -63,7 +61,7 @@ afterEach(async () => {
|
||||
scaffold = undefined
|
||||
})
|
||||
|
||||
it('assembles the shipped Web catalog and keeps its access default', async () => {
|
||||
it('assembles the shipped Web catalog with the confined 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)
|
||||
@@ -76,8 +74,7 @@ it('assembles the shipped Web catalog and keeps its access default', async () =>
|
||||
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')
|
||||
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
|
||||
expect(scaffold.ctx.approval.config.policy).toBe('ask')
|
||||
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
}, 120_000)
|
||||
|
||||
@@ -26,6 +26,8 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let out = ''
|
||||
@@ -184,7 +186,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
|
||||
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
@@ -220,7 +222,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
@@ -252,6 +254,10 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
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'))
|
||||
const systemMessage = captured.messages?.find(message => message.role === 'system')
|
||||
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
|
||||
.replace('{{webUrl}}', baseUrl)
|
||||
expect(systemMessage?.content).toContain(expectedWebSection)
|
||||
expect(workspaceMessage).toMatchInlineSnapshot(`
|
||||
{
|
||||
"content": "<system-reminder>
|
||||
@@ -588,7 +594,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
// Bash renders through the third-party sample registration. Match that
|
||||
// exact row: other clickable variants (for example Think disclosure)
|
||||
// may precede the tool call in document order.
|
||||
const toolRow = page.locator('[data-sample="bash-global"]')
|
||||
const toolRow = page.locator('[data-sample="bash"]')
|
||||
await toolRow.waitFor({ timeout: 120_000 })
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- 'heading "Using ONE run_code program: run" [level=1]'
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Using ONE run_code program: run" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -38,7 +39,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use only Cordis tools. First" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use only Cordis tools. First" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -53,7 +54,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
|
||||
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
|
||||
You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the bash tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the bash tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -33,7 +34,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
8
apps/web/tests/snapshots/goal-bar/active.expected.md
Normal file
8
apps/web/tests/snapshots/goal-bar/active.expected.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- img
|
||||
- text: Ongoing Goal guard rapid clear clicks
|
||||
- button "Pause goal":
|
||||
- img
|
||||
- button "Edit goal":
|
||||
- img
|
||||
- button "Clear goal":
|
||||
- img
|
||||
@@ -28,7 +28,7 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with the single word" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -25,7 +26,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -22,7 +23,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -18,7 +19,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -17,7 +18,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -27,7 +28,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the read tool twice" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -36,7 +37,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- 'heading "Plan a small change: add" [level=1]'
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Plan a small change: add" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -38,7 +39,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the ask_user_question tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -33,7 +34,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -18,7 +19,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -22,6 +23,8 @@
|
||||
- img
|
||||
- button "Remove queued message":
|
||||
- img
|
||||
- button "Steer queued message":
|
||||
- img
|
||||
- listitem:
|
||||
- textbox "Edit queued message": Edited queue item
|
||||
- button "Save queued message":
|
||||
@@ -31,7 +34,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
43
apps/web/tests/snapshots/queue-actions/layout.expected.md
Normal file
43
apps/web/tests/snapshots/queue-actions/layout.expected.md
Normal file
@@ -0,0 +1,43 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
- text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- region "To-dos":
|
||||
- button "To-dos 1/2 tasks · 1 in progress"
|
||||
- img
|
||||
- text: Ongoing Goal Keep the composer context panels aligned
|
||||
- button "Pause goal":
|
||||
- img
|
||||
- button "Edit goal":
|
||||
- img
|
||||
- button "Clear goal":
|
||||
- img
|
||||
- button "2 queued messages"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -32,10 +33,12 @@
|
||||
- img
|
||||
- button "Remove queued message":
|
||||
- img
|
||||
- button "Steer queued message":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Reply with a one-sentence description" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -21,10 +22,12 @@
|
||||
- img
|
||||
- button "Remove queued message":
|
||||
- img
|
||||
- button "Steer queued message":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the read tool twice" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -40,11 +41,11 @@
|
||||
- img
|
||||
- text: Context injection
|
||||
- img
|
||||
- text: permission preset workspace-write
|
||||
- text: permission preset read-only
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 'button "Access mode, current: Read Only"': Read Only
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the read tool twice" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -42,7 +43,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
- img
|
||||
- text: 关闭
|
||||
- text: 权限 选择新会话的默认权限模式
|
||||
- button "Full access":
|
||||
- text: Full access
|
||||
- button "Workspace Write":
|
||||
- text: Workspace Write
|
||||
- img
|
||||
- text: 语言
|
||||
- button "中文":
|
||||
@@ -28,3 +28,7 @@
|
||||
- button "跟随系统" [pressed]:
|
||||
- img
|
||||
- text: 跟随系统
|
||||
- text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送":
|
||||
- text: 排队发送
|
||||
- img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the ask_user_question tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -22,6 +23,9 @@
|
||||
- img
|
||||
- text: Ask question waiting
|
||||
- status: Deep diving...
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy":
|
||||
- img
|
||||
- region "Ready to continue?":
|
||||
- text: Checkpoint
|
||||
- heading "Ready to continue?" [level=2]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use the ask_user_question tool to" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -20,7 +21,11 @@
|
||||
- img
|
||||
- img
|
||||
- text: Ask question 1/1 answered
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
|
||||
- img
|
||||
- img
|
||||
@@ -34,7 +39,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "Explain event sourcing in one (1) now" [selected]
|
||||
- treeitem "Ask a research subagent to now"
|
||||
@@ -0,0 +1,18 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher"
|
||||
- text: /
|
||||
- button "example editor" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Give one concrete event sourcing example. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- status:
|
||||
- strong: This subagent is read-only for now
|
||||
- text: The parent session is offline; reopen it to continue sending messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "Ask a research subagent to now"
|
||||
@@ -0,0 +1,3 @@
|
||||
- tree "Subagent sessions":
|
||||
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…
|
||||
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…
|
||||
@@ -0,0 +1,8 @@
|
||||
- tree "Subagent sessions":
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running just now" [expanded] [level=1]:
|
||||
- button "Collapse event-sourcing researcher descendants":
|
||||
- img
|
||||
- text: event-sourcing researcher Explain event sourcing in one · continuable · not running just now
|
||||
- group:
|
||||
- treeitem "example editor continuable · not running just now" [level=2]
|
||||
- treeitem "event-sourcing reviewer one-shot · not running just now" [level=1]
|
||||
@@ -0,0 +1,50 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- button "1 subagent":
|
||||
- text: 1 subagent
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Explain event sourcing in one sentence. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: 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.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Now give the same explanation to a human reader. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: 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.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 2 steps Context 6% of 128K Cache hit 99% Input 15.6K tok · Output 158 tok
|
||||
@@ -0,0 +1 @@
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
@@ -1,5 +1,6 @@
|
||||
- banner:
|
||||
- heading "Use web_search to search exactly" [level=1]
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -25,7 +26,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
// Web e2e scenario: mid-turn steering over the host wire. The Web UI has no
|
||||
// steer entry, so the steer is POSTed from the page over the same
|
||||
// same-origin /api transport the client uses. Everything downstream is
|
||||
// product: the gateway routes mode:'steer' to Agent.steer, the loop drains
|
||||
// it at the step boundary into a durable steering/message event, the SSE mux
|
||||
// pushes it, and the transcript shows the text as a plain bubble (no
|
||||
// interjection chrome). The question composer supplies the deterministic
|
||||
// mid-turn window: while ask_user_question blocks, the turn is provably
|
||||
// running, so record and replay perform the identical steer-then-answer
|
||||
// sequence with zero timing dependence — and the recorded final reply proves
|
||||
// the steer reached the MODEL (it obeys an instruction that only the
|
||||
// steering message carries).
|
||||
// Web e2e scenarios for both steering entry points: QueueDock strictly
|
||||
// transfers one queued occurrence, while the complementary composer gestures
|
||||
// choose Queue or Steer. The question tool supplies a deterministic pending-
|
||||
// steering snapshot before the step can drain.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -26,16 +18,18 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// Two goldens for the two distinct states this interaction produces: the
|
||||
// mid-turn moment (steer ACCEPTED but deliberately invisible — the loop
|
||||
// drains steering at the step boundary, so no steering text exists while
|
||||
// the question still blocks the step) and the settled transcript (plain
|
||||
// bubble in place, final reply obeying it). The pair pins the timing
|
||||
// semantics visually: if the client ever starts rendering pending steers
|
||||
// eagerly, the mid-steer golden flips first.
|
||||
// Two goldens pin the transient Host projection and its durable handoff: the
|
||||
// mid-turn state renders accepted steering from session/queue while the
|
||||
// question blocks admission, then the settled state renders the same message
|
||||
// from steering/message beside the reply that obeys it.
|
||||
const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
|
||||
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
// The question composer replaces the textarea, so fill → Queue row → Steer
|
||||
// must finish inside the first replay chunk window. At 15 ms that window is
|
||||
// shorter than Playwright's round trips; 100 ms supplies test-only headroom,
|
||||
// while larger values lengthen all three replay scenarios linearly.
|
||||
const REPLAY_PACE_MS = 100
|
||||
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
|
||||
const STEER = 'Interjection: include the word BANANA in your final reply.'
|
||||
@@ -56,15 +50,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let liveSessionId: string | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
||||
scaffold.ctx.on('session/event', (session, event) => {
|
||||
liveSessionId ??= session.id
|
||||
sessionEvents.push(event)
|
||||
})
|
||||
scaffold = await launchWebScaffold(MODE === 'record'
|
||||
? {}
|
||||
: { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
@@ -79,7 +71,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('steers during the blocked step; the message is logged, rendered, and obeyed', async () => {
|
||||
it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
|
||||
if (MODE !== 'record') {
|
||||
// The steer must NOT be a user/message — it lands as steering/message.
|
||||
@@ -91,36 +83,29 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
|
||||
// The blocked composer is the mid-turn barrier: its presence proves the
|
||||
// ask_user_question step is executing, i.e. the turn is running NOW.
|
||||
// Enter remains the Queue gesture. The row action then atomically moves
|
||||
// this exact occurrence into the current turn's steering outbox.
|
||||
await input.fill(STEER)
|
||||
await input.press('Enter')
|
||||
const queued = page.getByText(STEER, { exact: true })
|
||||
await queued.waitFor({ timeout: 10_000 })
|
||||
const queuedRow = page.getByRole('listitem').filter({ hasText: STEER })
|
||||
const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' })
|
||||
await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await steerButton.click({ timeout: 10_000 })
|
||||
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
|
||||
// A timeout while the Queue row remains means strict steer lost to a
|
||||
// closing window (`steer-unavailable`); inspect replay pacing first.
|
||||
await pendingSteering.waitFor({ timeout: 10_000 })
|
||||
|
||||
// The blocked composer keeps steering pending long enough to observe the
|
||||
// Host-authoritative mirror before the loop admits it durably.
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
|
||||
|
||||
// Steer through the real wire from the page (same envelope + endpoint the
|
||||
// web client's session.prompt uses). accepted:true is the transport proof.
|
||||
expect(liveSessionId).toBeDefined()
|
||||
const reply = await page.evaluate(async ({ sessionId, text }) => {
|
||||
const response = await fetch('/api/session.prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: crypto.randomUUID(),
|
||||
method: 'session.prompt',
|
||||
payload: { sessionId, mode: 'steer', content: [{ type: 'text', text }] },
|
||||
}),
|
||||
})
|
||||
return await response.json() as { result?: { ok?: boolean } }
|
||||
}, { sessionId: liveSessionId!, text: STEER })
|
||||
expect(reply.result?.ok).toBe(true)
|
||||
|
||||
if (MODE !== 'record') {
|
||||
// Mid-turn golden: the ACCEPTED steer is durable in the inbox but the
|
||||
// loop drains steering only at the step boundary, so no steering/message
|
||||
// exists yet and no steer text renders — the composer still blocks,
|
||||
// alone. The DOM is stable here (no further SSE frames can arrive until
|
||||
// the question is answered), making this state capturable.
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(1)
|
||||
expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0)
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE)
|
||||
@@ -156,6 +141,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
// Visible: the plain steering bubble plus the reply that obeys it
|
||||
// (steer text + final reply each contain the marker word).
|
||||
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(0)
|
||||
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.locator('[data-question-key]').count()).toBe(0)
|
||||
// Settled golden: steer text between the question round trip and the
|
||||
@@ -170,3 +156,120 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('web e2e: composer shortcut steers directly', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { 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()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering'))
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled(30_000)
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await input.fill(STEER)
|
||||
await input.press('Meta+Enter')
|
||||
await expect.poll(() => input.inputValue(), { timeout: 5_000 }).toBe('')
|
||||
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
|
||||
await pendingSteering.waitFor({ timeout: 10_000 })
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await settled
|
||||
|
||||
const steerEvents = sessionEvents.filter(event => event.type === 'steering/message')
|
||||
expect(steerEvents).toHaveLength(1)
|
||||
expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
|
||||
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(0)
|
||||
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { 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()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('queues Cmd+Enter when plain Enter is configured to Steer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-swapped-shortcut'))
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Queue' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Steer' }).click()
|
||||
await dialog.getByRole('button', { name: 'Steer' }).waitFor({ timeout: 10_000 })
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
const settled = scaffold.whenTurnSettled(30_000)
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const queuedText = 'Queued by the complementary Cmd+Enter shortcut.'
|
||||
await input.fill(queuedText)
|
||||
await input.press('Meta+Enter')
|
||||
const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText })
|
||||
await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0)
|
||||
expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
|
||||
// Remove the asserted Queue row, then finish the recorded question turn
|
||||
// so replay teardown still proves that every fixture call was consumed.
|
||||
await queuedRow.getByRole('button', { name: 'Remove queued message' }).click()
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await settled
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
486
apps/web/tests/subagent-conversation.e2e.ts
Normal file
486
apps/web/tests/subagent-conversation.e2e.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
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 {
|
||||
SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
|
||||
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
|
||||
const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
|
||||
const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
|
||||
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
|
||||
const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const LABEL = 'event-sourcing researcher'
|
||||
const ONE_SHOT_LABEL = 'event-sourcing reviewer'
|
||||
const NESTED_LABEL = 'example editor'
|
||||
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
|
||||
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
|
||||
|
||||
function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
|
||||
const [header, ...eventLines] = source.trimEnd().split('\n')
|
||||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||||
const childHeader = header
|
||||
.replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
|
||||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442')
|
||||
if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
|
||||
const continued = eventLines.map(line => line
|
||||
.replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
|
||||
.replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
|
||||
.replaceAll('"turn":1', '"turn":2'))
|
||||
return [childHeader, ...eventLines, ...continued, ''].join('\n')
|
||||
}
|
||||
|
||||
async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise<void> {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (scaffold.ctx.agents.get(id) !== undefined) {
|
||||
if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: persisted subagent conversation and human continuation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let sidecarRoot: string
|
||||
let childId: SessionId
|
||||
let oneShotId: SessionId
|
||||
let grandchildId: SessionId
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
|
||||
const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
|
||||
const childFixturePath = join(sidecarRoot, 'child.jsonl')
|
||||
await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: BASE_FIXTURE,
|
||||
replayChildFixtures: [childFixturePath],
|
||||
paceMs: 25,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const parent = scaffold.ctx.agents.roots()[0]
|
||||
if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||||
const parentSettled = scaffold.whenTurnSettled()
|
||||
const parentInput = page.locator('textarea:enabled').first()
|
||||
await parentInput.fill(PARENT_PROMPT)
|
||||
await parentInput.press('Enter')
|
||||
expect(await parentSettled).toBe(parent.id)
|
||||
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: LABEL,
|
||||
signal: new AbortController().signal,
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
|
||||
parent,
|
||||
},
|
||||
})
|
||||
childId = started.childId
|
||||
await waitForAgentToSettle(scaffold, childId)
|
||||
oneShotId = sessionId('recorded-one-shot')
|
||||
const oneShotAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: oneShotId,
|
||||
createdAt: oneShotAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
delegationDepth: 1,
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(oneShotId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: oneShotAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: oneShotAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Review the event sourcing explanation.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: oneShotAt + 2,
|
||||
data: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: oneShotAt + 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
grandchildId = sessionId('recorded-grandchild')
|
||||
const authoredAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: grandchildId,
|
||||
createdAt: authoredAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: childId,
|
||||
origin: 'subagent',
|
||||
delegationDepth: 2,
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(grandchildId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: authoredAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: authoredAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: authoredAt + 2,
|
||||
data: snapshotSubagentDescriptor({
|
||||
mode: 'continuable', provider: 'spawn', label: NESTED_LABEL,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: authoredAt + 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
|
||||
{
|
||||
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: oneShotId, mode: 'one-shot',
|
||||
label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
|
||||
{
|
||||
kind: 'child', id: grandchildId, mode: 'continuable',
|
||||
label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
// These two cold fixtures were authored after the page's initial
|
||||
// session.list and intentionally emitted no session-added frame. Reload
|
||||
// to exercise the restart baseline that discovers their full lineage.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const catalogButton = page.getByRole('button', { name: /subagents/ })
|
||||
await catalogButton.waitFor({ timeout: 15_000 })
|
||||
await catalogButton.click()
|
||||
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||||
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
|
||||
await catalogTree.press('Escape')
|
||||
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarRoot !== undefined) {
|
||||
await rm(sidecarRoot, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
|
||||
})
|
||||
|
||||
it('keeps known descendants reachable across a stale empty catalog response', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
|
||||
const pattern = '**/api/subagent.list'
|
||||
let firstClaimed = false
|
||||
let emptyDelivered = false
|
||||
let trailingRequested = false
|
||||
let releaseCatalog = (): void => {}
|
||||
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
|
||||
await page.route(pattern, async (route) => {
|
||||
if (firstClaimed) {
|
||||
const response = await route.fetch()
|
||||
trailingRequested = true
|
||||
await catalogHeld
|
||||
await route.fulfill({ response })
|
||||
return
|
||||
}
|
||||
firstClaimed = true
|
||||
const response = await route.fetch()
|
||||
const body = await response.json() as {
|
||||
result: { ok: true; value: { entries: unknown[] } } | { ok: false }
|
||||
}
|
||||
if (body.result.ok) body.result.value.entries = []
|
||||
await route.fulfill({ response, json: body })
|
||||
emptyDelivered = true
|
||||
})
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
try {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
|
||||
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
|
||||
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||||
await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
|
||||
expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
|
||||
await compareOrRefreshGolden(
|
||||
STALE_CATALOG_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
releaseCatalog()
|
||||
await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
|
||||
await tree.press('Escape')
|
||||
} finally {
|
||||
releaseCatalog()
|
||||
await page.unroute(pattern)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands a persisted grandchild progressively without activating either level', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
expect(await page.getByRole('button', {
|
||||
name: `Expand ${ONE_SHOT_LABEL} descendants`,
|
||||
}).count()).toBe(0)
|
||||
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="Subagent sessions"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
|
||||
})
|
||||
|
||||
it('opens the completed child from persistence without activating it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(
|
||||
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(1)
|
||||
if (scaffold.ctx.agents.get(childId) !== undefined) {
|
||||
throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
|
||||
}
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor()
|
||||
const sidebar = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="Sessions"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
})
|
||||
|
||||
it('continues through FIFO follow-up admission and receives the child mux events', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
|
||||
const ended = new Promise<void>((resolveEnded, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error('subagent follow-up did not reach turn/end'))
|
||||
}, 30_000)
|
||||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (session.id !== childId || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
resolveEnded()
|
||||
})
|
||||
})
|
||||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||||
await input.fill(FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.get(childId)?.status,
|
||||
{ timeout: 10_000 },
|
||||
).toBe('running')
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button').first().click()
|
||||
const runningTrigger = page.getByRole('button', { name: '3 subagents running' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await runningTrigger.click()
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
await ended
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*not running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByRole('button', { name: '3 subagents' })
|
||||
.locator('[data-state="ongoing"]').count()).toBe(0)
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||||
})
|
||||
|
||||
it('matches the settled addressed-conversation aria golden and stays clean', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('opens an unavailable persisted grandchild after recording the available child', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
|
||||
await page.getByRole('button', { name: '1 subagent' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
|
||||
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
const crumbs = await hierarchy.getByRole('button').allTextContents()
|
||||
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await compareOrRefreshGolden(
|
||||
UNAVAILABLE_GRANDCHILD_EXPECTED,
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a one-shot child as permanently read-only history', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
|
||||
const parentSession = page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem')
|
||||
.last()
|
||||
await parentSession.click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
|
||||
await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork'))
|
||||
await page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem', { name: /Ask a research subagent to/ })
|
||||
.click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
|
||||
const forkResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/session.fork')
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
|
||||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||||
await expect.poll(
|
||||
() => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(3)
|
||||
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
expect(await hierarchy.getByRole('button').count()).toBe(1)
|
||||
await compareOrRefreshGolden(
|
||||
FORK_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
})
|
||||
|
||||
it('cold-resumes the original subagent while its ordinary fork stays active', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
|
||||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.locator('textarea:enabled').first().waitFor()
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
|
||||
const forkResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/session.fork')
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
const forkReceipt = await (await forkResponse).json() as {
|
||||
result: { ok: true; value: { sessionId: string } } | { ok: false }
|
||||
}
|
||||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||||
if (!forkReceipt.result.ok) return
|
||||
const forkId = sessionId(forkReceipt.result.value.sessionId)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||||
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.waitFor()
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
await input.fill(POST_FORK_FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
const promptReceipt = await (await promptResponse).json() as {
|
||||
result: { ok: true } | { ok: false; error: { code: string; message: string } }
|
||||
}
|
||||
if (!promptReceipt.result.ok) {
|
||||
throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`)
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
const messageIndex = loaded.events.findIndex(event => event.type === 'user/message'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP))
|
||||
return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end')
|
||||
}, { timeout: 30_000 }).toBe(true)
|
||||
expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,7 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
|
||||
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
||||
export function requireDist(): void {
|
||||
if (!existsSync(DIST_INDEX)) {
|
||||
throw new Error('web app dist not built — run `pnpm --filter @deepseek-ai/dsh-frontend build` (pnpm run test:web does this first)')
|
||||
throw new Error('web app dist not built — run `pnpm run build` from the repository root (`pnpm run test:web` does this first)')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
apps/web/tests/support/listen-probe.mjs
Normal file
9
apps/web/tests/support/listen-probe.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { Server } from 'node:net'
|
||||
|
||||
const marker = process.env.DSH_LISTEN_PROBE_MARKER
|
||||
const listen = Server.prototype.listen
|
||||
Server.prototype.listen = function (...args) {
|
||||
if (marker !== undefined) appendFileSync(marker, 'listen\n')
|
||||
return listen.apply(this, args)
|
||||
}
|
||||
63
apps/web/tests/vite-entry.e2e.ts
Normal file
63
apps/web/tests/vite-entry.e2e.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Bare Vite must fail before it can present a bootless shell as a working GUI. */
|
||||
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createServer } from 'node:net'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const WEB_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/** Reserve an available loopback port, then release it for the child invocation. */
|
||||
async function freePort(): Promise<number> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('port probe returned no address')
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
}))
|
||||
return address.port
|
||||
}
|
||||
|
||||
describe('Web development entry', () => {
|
||||
it('rejects the package dev alias with the full-host correction', async () => {
|
||||
const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toContain('apps/web is not a standalone application')
|
||||
expect(result.stderr).toContain('dsh web')
|
||||
})
|
||||
|
||||
it('rejects the standalone Vite server with the full-host correction', async () => {
|
||||
const probeRoot = mkdtempSync(join(tmpdir(), 'dsh-vite-listen-probe-'))
|
||||
const marker = join(probeRoot, 'listen-called')
|
||||
const port = await freePort()
|
||||
try {
|
||||
const probeModule = fileURLToPath(new URL('./support/listen-probe.mjs', import.meta.url))
|
||||
const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], {
|
||||
cwd: WEB_ROOT,
|
||||
reject: false,
|
||||
timeout: 10_000,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_LISTEN_PROBE_MARKER: marker,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import ${pathToFileURL(probeModule).href}`.trim(),
|
||||
},
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toContain('apps/web is not a standalone application')
|
||||
expect(result.stderr).toContain('dsh web')
|
||||
expect(result.stderr).toContain('window.__DSH_BOOT__')
|
||||
expect(existsSync(marker), 'Vite called Server.listen before rejecting standalone serve mode').toBe(false)
|
||||
} finally {
|
||||
rmSync(probeRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/workspace-management.e2e.ts",
|
||||
"tests/replay-round-trip.e2e.ts",
|
||||
"tests/hmr-live.e2e.ts",
|
||||
"tests/seeded-history.e2e.ts",
|
||||
"tests/sidebar-scrollbar.e2e.ts",
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
@@ -50,7 +51,9 @@
|
||||
"tests/permission-policy-context.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts"
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
|
||||
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
|
||||
+ 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). '
|
||||
+ 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.'
|
||||
|
||||
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
|
||||
function rejectStandaloneServe(): Plugin {
|
||||
return {
|
||||
name: 'dsh-reject-standalone-web-serve',
|
||||
config(_config, env) {
|
||||
if (env.command === 'serve') throw new Error(STANDALONE_ERROR)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [rejectStandaloneServe(), react()],
|
||||
resolve: {
|
||||
// Workspace packages resolve to SOURCE: package.json exports point at lib
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
|
||||
Reference in New Issue
Block a user