Merge branch 'master' into fix/web-favicon-dark-mode
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: dd29f7fc03a783079ea3194de99589c1f545be5b
|
||||
README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc
|
||||
README.md: 4fae5338a89ce12c2620e123530acf883ae9efff
|
||||
README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab
|
||||
|
||||
@@ -9,15 +9,27 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. |
|
||||
| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). |
|
||||
| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. |
|
||||
| `dsh web` | Alias of `--profile web`. |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
|
||||
|
||||
The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
|
||||
The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
|
||||
|
||||
## App arguments
|
||||
|
||||
The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments:
|
||||
|
||||
```sh
|
||||
dsh --profile web --port 8080 # --port belongs to the web app
|
||||
dsh --profile tui --resume <id> # --resume belongs to the terminal app
|
||||
dsh --profile headless "run the tests"
|
||||
dsh --profile web --help # the web app's flags, not the launcher's
|
||||
dsh --help # the launcher's own help
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher.
|
||||
|
||||
|
||||
@@ -9,18 +9,30 @@
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 |
|
||||
| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 |
|
||||
| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 |
|
||||
| `dsh web` | `--profile web` 的别名。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
|
||||
## 应用参数
|
||||
|
||||
启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,任何注入它的应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点:
|
||||
|
||||
```sh
|
||||
dsh --profile web --port 8080 # --port belongs to the web app
|
||||
dsh --profile tui --resume <id> # --resume belongs to the terminal app
|
||||
dsh --profile headless "run the tests"
|
||||
dsh --profile web --help # the web app's flags, not the launcher's
|
||||
dsh --help # the launcher's own help
|
||||
```
|
||||
|
||||
## Profile
|
||||
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
|
||||
[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。
|
||||
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。
|
||||
|
||||
@@ -8,12 +8,10 @@ The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cfg["packages/bundle/base/cordis.patch.yml<br/>cordis.yml"]
|
||||
plugin_dsh_base_timer["timer<br/>@cordisjs/plugin-timer"]
|
||||
plugin_dsh_base_timer["timer<br/>@deepseek-ai/cordis-plugin-timer"]
|
||||
cfg --> plugin_dsh_base_timer
|
||||
plugin_dsh_base_hmr["hmr<br/>@cordisjs/plugin-hmr"]
|
||||
plugin_dsh_base_hmr["hmr<br/>@deepseek-ai/cordis-plugin-hmr"]
|
||||
cfg --> plugin_dsh_base_hmr
|
||||
plugin_dsh_base_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"]
|
||||
cfg --> plugin_dsh_base_repository_plugins
|
||||
plugin_dsh_base_llm["llm<br/>@deepseek-ai/dsh-llm"]
|
||||
cfg --> plugin_dsh_base_llm
|
||||
plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"]
|
||||
@@ -112,6 +110,10 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_subagent_spawn
|
||||
plugin_dsh_base_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
|
||||
cfg --> plugin_dsh_base_subagent_fork
|
||||
plugin_dsh_base_subagent_codex["subagent-codex<br/>@deepseek-ai/dsh-subagent-codex"]
|
||||
cfg --> plugin_dsh_base_subagent_codex
|
||||
plugin_dsh_base_subagent_claude_code["subagent-claude-code<br/>@deepseek-ai/dsh-subagent-claude-code"]
|
||||
cfg --> plugin_dsh_base_subagent_claude_code
|
||||
plugin_dsh_base_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
|
||||
cfg --> plugin_dsh_base_tool_subagent_control
|
||||
plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
|
||||
@@ -166,9 +168,8 @@ flowchart LR
|
||||
|
||||
| Plugin id | Package / module |
|
||||
| --- | --- |
|
||||
| `timer` | `@cordisjs/plugin-timer` |
|
||||
| `hmr` | `@cordisjs/plugin-hmr` |
|
||||
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
|
||||
| `timer` | `@deepseek-ai/cordis-plugin-timer` |
|
||||
| `hmr` | `@deepseek-ai/cordis-plugin-hmr` |
|
||||
| `llm` | `@deepseek-ai/dsh-llm` |
|
||||
| `session` | `@deepseek-ai/dsh-session` |
|
||||
| `typert` | `@deepseek-ai/dsh-typert-registry` |
|
||||
@@ -218,6 +219,8 @@ flowchart LR
|
||||
| `subagent` | `@deepseek-ai/dsh-subagent` |
|
||||
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
|
||||
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
|
||||
| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` |
|
||||
| `subagent-claude-code` | `@deepseek-ai/dsh-subagent-claude-code` |
|
||||
| `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` |
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# All three register into the host `tools` registry and provide nothing, so
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -63,11 +63,6 @@
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
@@ -195,6 +190,27 @@
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
name: 代码模式
|
||||
description: 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
|
||||
description: 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。
|
||||
order: 2
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# All three register into the host `tools` registry and provide nothing, so
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -57,11 +57,6 @@
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
@@ -182,6 +177,27 @@
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
name: 创造模式
|
||||
description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
description: 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
order: 4
|
||||
|
||||
@@ -26,6 +26,34 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre
|
||||
3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster.
|
||||
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above.
|
||||
|
||||
### Native product subagents
|
||||
|
||||
Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field.
|
||||
|
||||
Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:
|
||||
|
||||
```yaml
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product.
|
||||
|
||||
The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete.
|
||||
|
||||
## The rule that catches people
|
||||
|
||||
@@ -1,39 +1,70 @@
|
||||
# The `minimal` agent preset: the two-tool benchmark surface.
|
||||
# The `minimal` agent preset: a fixed-prompt, two-tool coding surface.
|
||||
#
|
||||
# The native model surface is exactly persistent `bash` plus
|
||||
# `str_replace_editor`. Everything else a session could reach — skills, goals,
|
||||
# plan mode, delegation, workflows, todo, web — is simply absent rather than
|
||||
# disabled, because a preset composes what an agent has instead of subtracting
|
||||
# from a shared default.
|
||||
#
|
||||
# The host composition is unchanged: this agent still runs inside the same
|
||||
# sandbox, approval, persistence, and model routing as any other session.
|
||||
# The persona is the complete system prompt, so global identity, Web surface,
|
||||
# tool guidance, and later assembly listeners cannot add prompt text. The model
|
||||
# composes only the persistent `bash` and `str_replace_editor` tools.
|
||||
|
||||
- id: persona
|
||||
name: '@deepseek-ai/dsh-persona'
|
||||
config:
|
||||
text: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
text: You are a helpful software engineer assistant.
|
||||
complete: true
|
||||
|
||||
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# never reached the model's shell at all. `tool-bash` consumes the host registry
|
||||
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
|
||||
# sandbox policy owns it.
|
||||
#
|
||||
# `run_in_background` is off because this preset mounts no `tool-tasks`. The
|
||||
# host registry already refuses a start for an owner no attached control
|
||||
# surface serves, so this is not the safety boundary — it is the model-facing
|
||||
# one: an agent that could never collect a task should not be offered the
|
||||
# parameter at all, and disabling it drops the parameter from the schema.
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
# The PTY registry is an agent-owned service, so it lives in an entry-local
|
||||
# realm. The backend still consumes the host sandbox policy and subprocess
|
||||
# implementation, while the tool registers into this agent's scoped catalog.
|
||||
- id: persistent-shell
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
pty: true
|
||||
config:
|
||||
enableRunInBackground: false
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
description: |-
|
||||
Run commands in a bash shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* You do have access to a mirror of common linux and python packages via apt and pip.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.
|
||||
|
||||
# The editor requires absolute paths unconditionally.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# Model capacity comes from routed model metadata; this block states the
|
||||
# compaction policy explicitly.
|
||||
- id: compaction
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
tokenMeter: true
|
||||
compact: true
|
||||
config:
|
||||
- id: token-meter
|
||||
name: '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
config:
|
||||
thresholdRatio: 0.8
|
||||
retainTokens: 20480
|
||||
summarizationProvider: ''
|
||||
summarizationModel: ''
|
||||
maxTokens: 8192
|
||||
compactionRetries: 1
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
name: 极简模式
|
||||
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
order: 3
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# All three register into the host `tools` registry and provide nothing, so
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -56,11 +56,6 @@
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
@@ -194,6 +189,27 @@
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
name: 标准模式
|
||||
description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
description: 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
order: 1
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Opt-in Web shell for the RL core agent contract. The model receives exactly
|
||||
# the configured persona plus the native `bash` and `str_replace_editor`
|
||||
# schemas; the Web host, browser shell, persistence, and permission stack stay.
|
||||
|
||||
# Match the Claude SWE-compatible RL core prompt. Disabling the Web runtime's
|
||||
# surface context removes its GUI orientation, managed shell variables, and the
|
||||
# launcher's source-checkout section through one configuration contract.
|
||||
# Workspace instructions are model-visible user context rather than a system
|
||||
# section, but RL core disables them as part of the same prompt contract.
|
||||
- id: system-prompt
|
||||
config:
|
||||
includeHarnessIdentity: false
|
||||
persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.'
|
||||
|
||||
- id: web-runtime
|
||||
config:
|
||||
surfaceContext: false
|
||||
|
||||
- id: workspace-context
|
||||
disabled: true
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
mode: native
|
||||
|
||||
# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
|
||||
# always-registered exit_plan_mode tool even while the session is not planning.
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-tasks
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs-search
|
||||
disabled: true
|
||||
|
||||
- id: tool-web
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-control
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-fork
|
||||
disabled: true
|
||||
|
||||
- id: tool-workflow
|
||||
disabled: true
|
||||
|
||||
- id: tool-todo
|
||||
disabled: true
|
||||
|
||||
# These consumers are shared defaults on the ordinary shipped surfaces, but
|
||||
# this opt-in profile keeps exactly its two named tools.
|
||||
- id: tool-goal
|
||||
disabled: true
|
||||
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
# The matching browser controls must not offer surfaces whose tool this
|
||||
# overlay omits: the panels would render for a capability the model does not
|
||||
# have. Turning the row off no longer removes a tool — `ui-question`'s host
|
||||
# half is empty and `tool-ask-user` is composed per preset — so this is a UI
|
||||
# decision now, not a capability one.
|
||||
- id: ui-plan
|
||||
disabled: true
|
||||
|
||||
- id: ui-question
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
# This backend consumes the existing Web sandbox and permission policy.
|
||||
# It loads only on Linux/macOS; Windows and other platforms fail at boot.
|
||||
# Its 300s send wait matches the persistent Bash command timeout instead of
|
||||
# pty-local's 30s default. An open persistent shell fences permission-mode
|
||||
# changes until it closes.
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
# The editor consumes the Web fs-sandbox provider and therefore retains
|
||||
# the selected session permission mode.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "apps/cli"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"dsh": "lib/bin.js"
|
||||
@@ -13,10 +20,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@cordisjs/plugin-hmr": "workspace:*",
|
||||
"@cordisjs/plugin-include": "workspace:*",
|
||||
"@cordisjs/plugin-loader": "workspace:*",
|
||||
"@cordisjs/plugin-timer": "workspace:*",
|
||||
"@deepseek-ai/cordis-plugin-hmr": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-tool-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-base": "workspace:^",
|
||||
@@ -27,6 +34,8 @@
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||
"@deepseek-ai/dsh-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-headless": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
@@ -63,7 +72,7 @@
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"js-yaml": "^4.2.0",
|
||||
"node-addon-require-builtin": "^0.1.4"
|
||||
},
|
||||
@@ -77,6 +86,7 @@
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -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/reference/README.md
|
||||
README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91
|
||||
README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70
|
||||
README.md: fd0647347312051a1814a5e3464b34032ae70dfc
|
||||
README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
|
||||
This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
|
||||
|
||||
## Profile boot
|
||||
|
||||
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch <path>` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
|
||||
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile <name> "<task>"` command instead of reaching the row's raw required-field error.
|
||||
### App arguments
|
||||
|
||||
The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
|
||||
|
||||
A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
|
||||
|
||||
Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
|
||||
|
||||
The shipped apps own these command lines:
|
||||
|
||||
| Profile | Arguments |
|
||||
|---|---|
|
||||
| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` |
|
||||
| `headless` | the task text, as the positional argument |
|
||||
|
||||
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
|
||||
Inspect the composed tree without booting it:
|
||||
|
||||
@@ -21,13 +36,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
|
||||
## One-shot run
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row.
|
||||
|
||||
The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
|
||||
|
||||
## Plugin management
|
||||
|
||||
@@ -43,12 +52,13 @@ Git-hosted plugins that ship sources build during install through their `prepare
|
||||
|
||||
## Web alias
|
||||
|
||||
`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
|
||||
`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
dsh web --patch ./extra.cordis.yml
|
||||
dsh web --dump-config
|
||||
dsh web --help
|
||||
```
|
||||
|
||||
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
|
||||
@@ -59,17 +69,15 @@ All modes treat the invoking directory as the default workspace root, load appli
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
|
||||
|
||||
`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback.
|
||||
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
|
||||
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
|
||||
|
||||
The empty `repository-plugins` row lets profile patch layers mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
|
||||
## Source launcher
|
||||
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
|
||||
本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
|
||||
|
||||
## Profile 启动
|
||||
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch <path>` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch <path>` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
|
||||
组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。
|
||||
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile <name> "<task>"`,而不会触发该行原始的必填字段错误。
|
||||
### 应用参数
|
||||
|
||||
启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,任何注入它的应用插件都可以解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。
|
||||
|
||||
一套组合只挂载一次。普通插件注入 `cmdlineArgs`、解析本应用参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖提供方服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。
|
||||
|
||||
启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
|
||||
|
||||
随附的各应用持有这些命令行:
|
||||
|
||||
| Profile | 参数 |
|
||||
|---|---|
|
||||
| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` |
|
||||
| `headless` | 任务文本,作为位置参数 |
|
||||
|
||||
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
|
||||
可在不启动的情况下检查组合出的配置树:
|
||||
|
||||
@@ -21,13 +36,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
|
||||
## 一次性运行
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。
|
||||
|
||||
启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用命令行提供方,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。
|
||||
|
||||
## 插件管理
|
||||
|
||||
@@ -43,12 +52,13 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
|
||||
|
||||
## Web 别名
|
||||
|
||||
`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
|
||||
`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
dsh web --patch ./extra.cordis.yml
|
||||
dsh web --dump-config
|
||||
dsh web --help
|
||||
```
|
||||
|
||||
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
|
||||
@@ -59,17 +69,15 @@ dsh web --dump-config
|
||||
|
||||
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
|
||||
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。
|
||||
|
||||
`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
|
||||
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
|
||||
|
||||
空 `repository-plugins` 行让 profile 的 patch 层能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 约定](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
|
||||
## 源码启动器
|
||||
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
/**
|
||||
* Commander adapter for the `dsh` command-line entry. The default command
|
||||
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
|
||||
* overlays. `run` owns one-shot task execution, defaulting to the headless
|
||||
* profile; `web` is a hardcoded alias for `--profile web` that adds the Web
|
||||
* flag family; `plugin` manages a profile's plugin dependencies by forwarding
|
||||
* to pnpm. Commander owns help, version, and parse errors.
|
||||
* Commander adapter for the `dsh` command line.
|
||||
*
|
||||
* The launcher parses only what it owns — which profile to boot, which extra
|
||||
* patch overlays to apply, and the config dumps — and hands **everything after
|
||||
* its own flags** to the booted tree verbatim, where injected app plugins parse
|
||||
* their own flag families and print their own `--help` (see
|
||||
* `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first
|
||||
* token this parser does not recognize starts the inner arguments, so
|
||||
* `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
|
||||
* and `dsh --profile web -h` prints the web app's help, not this one's.
|
||||
*
|
||||
* `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
|
||||
* plugin dependencies by forwarding to pnpm.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
|
||||
/** Boot a named profile. */
|
||||
/** Boot a named profile and hand it the invocation's inner arguments. */
|
||||
interface ProfileInvocation {
|
||||
mode: 'profile'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/** Run one task through a profile mounting the headless runner. */
|
||||
interface RunInvocation {
|
||||
mode: 'run'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
/** Non-blank task text joined from the variadic positional arguments. */
|
||||
task: string
|
||||
/** Everything after the launcher's own flags, verbatim, for injected app plugins. */
|
||||
args: string[]
|
||||
}
|
||||
|
||||
/** Print a composed profile tree and exit without booting. */
|
||||
@@ -37,21 +36,6 @@ interface DumpConfigInvocation {
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser UI: `dsh web` (alias of `--profile web`). Host and port remain
|
||||
* unvalidated pass-throughs to the webserver schema; absent values leave the
|
||||
* shipped web bundle values intact.
|
||||
*/
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
patches: string[]
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
/** Extra authorities for the /api browser-trust fence. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
|
||||
interface PluginInvocation {
|
||||
mode: 'plugin'
|
||||
@@ -61,31 +45,63 @@ interface PluginInvocation {
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
|
||||
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
/** Launcher flags shared by the default command and the `web` alias. */
|
||||
interface BootOptions {
|
||||
patch?: string[]
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
trustedHost?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}
|
||||
|
||||
/** Raw run-subcommand options straight from Commander. */
|
||||
interface RunOptions {
|
||||
profile: string
|
||||
patch?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
|
||||
* variadic — a variadic `--patch` would swallow a following positional task.
|
||||
* variadic — a variadic `--patch` would swallow the inner arguments.
|
||||
*/
|
||||
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
|
||||
|
||||
/** The launcher's own help text; each app prints its own. */
|
||||
const HELP_EXAMPLES = `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh --profile headless "run the tests" answer one task, print the result, and exit
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh --profile tui --resume <session> arguments after the launcher flags reach the app
|
||||
dsh --profile web --help the web app's own flags and help
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
`
|
||||
|
||||
/**
|
||||
* Resolve a boot or dump invocation from the launcher flags and the leftover
|
||||
* inner arguments.
|
||||
* @param program - the command whose options were parsed (the root, or the `web` alias).
|
||||
* @param profile - the profile these flags boot.
|
||||
* @param options - the launcher flags commander collected.
|
||||
* @param args - the leftover arguments, in argv order.
|
||||
* @returns the resolved invocation.
|
||||
*/
|
||||
function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
|
||||
return { mode: 'profile', profile, patches, args }
|
||||
}
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
// The dump is boot-free: it never runs app command-line providers, so it
|
||||
// cannot show what those flags would decide, and printing a tree that differs
|
||||
// from the same invocation's boot would mislead.
|
||||
if (args.length > 0) {
|
||||
program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`)
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
return { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve argv into one invocation, or print and exit for help, version, or an
|
||||
* error.
|
||||
@@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const program = new Command()
|
||||
// Annotated, not inferred: the actions below call back into `program`, and an
|
||||
// inferred type would be circular through its own chain.
|
||||
const program: Command = new Command()
|
||||
program
|
||||
.name('dsh')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh run "run the tests" answer one task, print the result, and exit
|
||||
dsh run --profile custom "run the tests" run one task through a custom one-shot profile
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
dsh web --port 8080 the web alias with its flag family
|
||||
`)
|
||||
.addHelpText('after', HELP_EXAMPLES)
|
||||
.exitOverride()
|
||||
// The launcher's flags come first and end at the first token it does not
|
||||
// know; everything from there on belongs to the booted app, including
|
||||
// its -h. `dsh -h` with no profile still prints this help, below.
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
|
||||
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--dump-config', 'print the composed profile tree and exit')
|
||||
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
|
||||
.action((options: {
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}) => {
|
||||
const profile = options.profile ?? program.error('error: --profile <name> is required')
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
return
|
||||
.action((args: string[], options: BootOptions & { profile?: string }) => {
|
||||
// With the app owning -h, the launcher's own help is what a bare
|
||||
// `dsh -h` (no profile to hand it to) must print.
|
||||
if (options.profile === undefined) {
|
||||
if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
|
||||
program.error('error: --profile <name> is required')
|
||||
}
|
||||
resolved = { mode: 'profile', profile, patches }
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
resolved = resolveBoot(program, profile, options, args)
|
||||
})
|
||||
|
||||
/** Reject parent options supplied before a subcommand. */
|
||||
const rejectParentOptions = (command: string): void => {
|
||||
const parent = program.opts<{
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}>()
|
||||
const parent = program.opts<BootOptions & { profile?: string }>()
|
||||
if (parent.profile !== undefined || parent.patch !== undefined
|
||||
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
|
||||
program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
|
||||
}
|
||||
}
|
||||
|
||||
const run = program.command('run').description('run one task through a profile mounting the headless runner')
|
||||
run
|
||||
.option('--profile <name>', 'one-shot profile under $DSH_HOME/profiles', 'headless')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.argument('<task...>', 'task text')
|
||||
.action((task: string[], options: RunOptions) => {
|
||||
rejectParentOptions('run')
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
const joined = task.join(' ')
|
||||
if (joined.trim() === '') program.error('error: run needs a non-blank task')
|
||||
resolved = { mode: 'run', profile, patches, task: joined }
|
||||
})
|
||||
|
||||
const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
|
||||
const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
|
||||
web
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the web app (see: dsh web --help)')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.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', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
|
||||
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
|
||||
.action((options: WebOptions) => {
|
||||
.action((args: string[], options: BootOptions) => {
|
||||
rejectParentOptions('web')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
// The dump is boot-free and does not derive flag patches; silently
|
||||
// dropping them would print a tree that differs from the same
|
||||
// invocation's boot.
|
||||
if (options.host !== undefined || options.port !== undefined || options.dev === true
|
||||
|| options.trustedHost !== undefined) {
|
||||
program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
|
||||
return
|
||||
}
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
resolved = {
|
||||
mode: 'web',
|
||||
patches,
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
dev: options.dev === true,
|
||||
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
|
||||
}
|
||||
resolved = resolveBoot(web, 'web', options, args)
|
||||
})
|
||||
|
||||
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
|
||||
|
||||
@@ -33,24 +33,10 @@ switch (invocation.mode) {
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
args: invocation.args,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'run': {
|
||||
const { runProfile } = await import('./profile-boot.ts')
|
||||
await runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
task: invocation.task,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation, loadLayeredEnv('dsh'))
|
||||
break
|
||||
}
|
||||
case 'plugin': {
|
||||
const { runPlugin } = await import('./plugin.ts')
|
||||
process.exit(runPlugin(invocation.profile, invocation.args))
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
|
||||
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own
|
||||
* `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
|
||||
* switch), mount the tree over the profile's empty root config, keep the
|
||||
* profile patch layer live, and wire fail-loud plus bounded shutdown.
|
||||
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
|
||||
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
|
||||
* tree over the profile's empty root config, keep the profile patch layer
|
||||
* live, and wire fail-loud plus bounded shutdown.
|
||||
*
|
||||
* App flags are not the launcher's business: the invocation's inner arguments
|
||||
* are provided to the tree through `ctx.cmdlineArgs`, where any injected app
|
||||
* plugin may read the same immutable snapshot.
|
||||
* @module @deepseek-ai/dsh/profile-boot
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FiberState, type Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { FiberState, type Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import {
|
||||
boot,
|
||||
composeEntries,
|
||||
@@ -25,7 +29,7 @@ import {
|
||||
watchUserPatches,
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
|
||||
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
|
||||
@@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im
|
||||
/** Harness-home directory holding locally authored agent presets. */
|
||||
const USER_PRESET_DIR = '.agent-presets'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
import { resolveWindowsShellLayer } from './windows-shell.ts'
|
||||
@@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me
|
||||
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
|
||||
const TELEMETRY_ROW_ID = 'telemetry-otel'
|
||||
|
||||
/** The one-shot runner row a `dsh run` task requires and configures. */
|
||||
/** The one-shot runner row: its presence means this composition exits by itself. */
|
||||
const HEADLESS_ROW_ID = 'headless-runner'
|
||||
|
||||
/** The empty root entry list every profile tree patches over. */
|
||||
@@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile {
|
||||
return profile
|
||||
}
|
||||
|
||||
/** Read-only row index of a profile composition before launcher flag patches. */
|
||||
export type ProfileRows = ReadonlyMap<string, { name?: string; config?: unknown }>
|
||||
|
||||
/** One profile's patch layers (application order) and the row index of its pre-flag composition. */
|
||||
interface ComposedProfile {
|
||||
profile: Profile
|
||||
@@ -116,14 +118,13 @@ interface ComposedProfile {
|
||||
windowsShellPatches: PatchOptions[]
|
||||
/** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
|
||||
homePatches: PatchOptions[]
|
||||
/** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
|
||||
overlayAndFlags: PatchOptions[]
|
||||
/** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
|
||||
overlays: PatchOptions[]
|
||||
/**
|
||||
* id → row of the pre-flag composition (bundles + user layers + overlays),
|
||||
* for flag merges and row checks. Flag patches must not insert rows the
|
||||
* launcher consults here (they only override values and insert dev glue).
|
||||
* id → row of the composed tree (bundles + user layers + overlays), for the
|
||||
* launcher's own row checks.
|
||||
*/
|
||||
rows: ProfileRows
|
||||
rows: ReadonlyMap<string, EntryOptions>
|
||||
}
|
||||
|
||||
/** The full patch stack of one composed profile, in application order. */
|
||||
@@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
|
||||
...composed.windowsShellPatches,
|
||||
...composed.profile.patches,
|
||||
...composed.homePatches,
|
||||
...composed.overlayAndFlags,
|
||||
...composed.overlays,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
|
||||
* is Windows), the profile's user layer, the home-level user layer
|
||||
* (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
|
||||
* every profile, so it outranks the per-profile layer), `--patch` overlays,
|
||||
* then flag patches derived from the composed rows, then the telemetry
|
||||
* switch.
|
||||
* then the telemetry switch.
|
||||
* @param name - the profile name.
|
||||
* @param patchFiles - `--patch` overlay paths, in argv order.
|
||||
* @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
|
||||
* @returns the profile, its patch layers, and the composed row index.
|
||||
*/
|
||||
function composeProfile(
|
||||
name: string,
|
||||
patchFiles: readonly string[],
|
||||
deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
|
||||
): ComposedProfile {
|
||||
const profile = prepareProfile(name)
|
||||
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
|
||||
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
|
||||
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
|
||||
const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
|
||||
const rows = new Map<string, { name?: string; config?: unknown }>()
|
||||
const rows = new Map<string, EntryOptions>()
|
||||
for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
|
||||
if (typeof row.id === 'string') rows.set(row.id, row)
|
||||
}
|
||||
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
|
||||
// The agent-preset roots are an assembly fact of every dsh launcher, not a
|
||||
// patch author's choice: the shipped set sits beside this app's config and
|
||||
// the user's own under the Harness home. Resolved per boot ($DSH_HOME may
|
||||
// differ per run) and only patched when the composed tree actually mounts
|
||||
// the roster — a one-shot `dsh run` composes agents from the same roster
|
||||
// `dsh web` offers.
|
||||
const composedOverlays = [...overlays]
|
||||
// Preset roots belong to every dsh composition that mounts the roster.
|
||||
if (rows.has('agent-presets')) {
|
||||
overlayAndFlags.push({
|
||||
composedOverlays.push({
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
|
||||
@@ -184,24 +177,20 @@ function composeProfile(
|
||||
})
|
||||
}
|
||||
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
||||
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
|
||||
return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows }
|
||||
if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
|
||||
return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows }
|
||||
}
|
||||
|
||||
/** Options for {@link runProfile}. */
|
||||
export interface RunProfileOptions {
|
||||
/** This run's frozen environment snapshot, provided before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
/** The profile name to boot. */
|
||||
profile: string
|
||||
/** `--patch` overlay paths, in argv order. */
|
||||
patchFiles: readonly string[]
|
||||
/** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
|
||||
deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[]
|
||||
/** `dsh run` task text; requires the composition to mount the headless runner row. */
|
||||
task?: string
|
||||
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
|
||||
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
|
||||
/** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
|
||||
args: readonly string[]
|
||||
}
|
||||
|
||||
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
|
||||
@@ -211,29 +200,16 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void
|
||||
|
||||
/**
|
||||
* Boot one profile invocation end to end and leave process lifetime to the
|
||||
* mounted plugins (or to the one-shot runner when `task` is present).
|
||||
* @param options - profile name, overlays, flag patches, and the optional task.
|
||||
* mounted plugins (or to a one-shot runner the composition mounts).
|
||||
* @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
|
||||
* @returns the settled root context and the shutdown controller.
|
||||
*/
|
||||
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
|
||||
const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches)
|
||||
if (options.task !== undefined) {
|
||||
if (!composed.rows.has(HEADLESS_ROW_ID)) {
|
||||
throw new Error(
|
||||
`dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row `
|
||||
+ '(the headless profile does)',
|
||||
)
|
||||
}
|
||||
composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } })
|
||||
} else if (composed.rows.has(HEADLESS_ROW_ID)) {
|
||||
// The inverse misuse: a one-shot composition booted without its task
|
||||
// would otherwise die in the runner row's schema with a raw "required"
|
||||
// error naming no fix.
|
||||
throw new Error(
|
||||
`dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
|
||||
+ `dsh run --profile ${options.profile} "<task>"`,
|
||||
)
|
||||
}
|
||||
const composed = composeProfile(options.profile, options.patchFiles)
|
||||
// A one-shot composition ends by itself, which changes what a signal means
|
||||
// and makes watching the user's patch layer pointless.
|
||||
const headlessRow = composed.rows.get(HEADLESS_ROW_ID)
|
||||
const oneShot = headlessRow !== undefined && headlessRow.disabled !== true
|
||||
|
||||
const app: { current?: Context } = {}
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
@@ -243,9 +219,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
shutdown.interrupt(code)
|
||||
}
|
||||
// Signals own teardown throughout the startup window, not only after boot()
|
||||
// settles: an inserted entry point can publish readiness before sibling rows
|
||||
// finish mounting.
|
||||
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
|
||||
// settles: an inserted provider can publish before sibling rows finish mounting.
|
||||
process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) })
|
||||
process.on('SIGINT', () => { interrupt(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
@@ -253,7 +228,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
|
||||
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
|
||||
// Recomposition for the live user layers: bundle layers below, overlays
|
||||
// and flag patches above, so a user edit can never displace them. BOTH
|
||||
// above, so a user edit can never displace them. Parsed app arguments are
|
||||
// not in here at all — they live in app-provided services that survive a
|
||||
// recomposition. BOTH
|
||||
// user files are re-read per generation (the HMR watcher hands us only the
|
||||
// changed file's patches, which one of the reads duplicates — fresh reads
|
||||
// keep the two watchers from stitching in each other's stale copy).
|
||||
@@ -267,19 +244,25 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
...composed.windowsShellPatches,
|
||||
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
|
||||
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
|
||||
...composed.overlayAndFlags,
|
||||
...composed.overlays,
|
||||
])
|
||||
// One-shot runs exit through the runner; watching would only hold the
|
||||
// process open after its exit request.
|
||||
const watchProfilePatch = options.task === undefined
|
||||
const watchProfilePatch = !oneShot
|
||||
// Cloned for the same insert-aliasing reason as composeLive: the boot
|
||||
// application must not mutate the objects later reloads recompose from.
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
|
||||
app.current = hostCtx
|
||||
// Before any config-tree entry mounts, so a plugin that resolves a
|
||||
// user-facing value at construction already sees this run's layers.
|
||||
// Before any config-tree entry mounts, so plugins resolve all launch-time
|
||||
// environment values from the same immutable provenance snapshot.
|
||||
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
|
||||
if (options.task !== undefined) {
|
||||
// The command line and bounded exit request are launcher facts available
|
||||
// to every app plugin that injects the argument snapshot.
|
||||
provideCmdline(hostCtx, {
|
||||
args: options.args,
|
||||
exit: code => void shutdown.shutdown(code),
|
||||
})
|
||||
if (oneShot) {
|
||||
const io: HeadlessIo = {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
@@ -287,11 +270,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
}
|
||||
hostCtx.provide('headlessIo', io)
|
||||
}
|
||||
await options.prepare?.(hostCtx, composed.rows)
|
||||
})
|
||||
app.current = ctx
|
||||
// A surface can dispose the whole tree while startup or this post-boot
|
||||
// watcher setup is still in flight. Loader presence and fiber state own
|
||||
// A surface can dispose the whole tree while boot or this post-boot watcher
|
||||
// setup is still in flight. Loader presence and fiber state own
|
||||
// liveness; the local signal fact distinguishes that expected exit race
|
||||
// from a real HMR error.
|
||||
if (watchProfilePatch
|
||||
@@ -308,9 +290,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// bare custom profile may not mount either.
|
||||
if (ctx.get('hmr') === undefined) {
|
||||
if (ctx.get('timer') === undefined) {
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
|
||||
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
|
||||
}
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
|
||||
}
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* `dsh web` — the browser-surface alias over the profile boot: `--profile web`
|
||||
* plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag
|
||||
* becoming a patch over the composed profile
|
||||
* tree. All web runtime glue (dist serving, prompt section, URL line) lives
|
||||
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
|
||||
* flag patches and the LAN-trust snapshot.
|
||||
* @module @deepseek-ai/dsh/web
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { runProfile, type ProfileRows } from './profile-boot.ts'
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Non-internal IPv4 interface addresses of this machine — the IP-literal
|
||||
* authorities an all-interfaces bind is reachable by on the LAN.
|
||||
* @returns the addresses in interface order (possibly empty).
|
||||
*/
|
||||
function lanIPv4Addresses(): string[] {
|
||||
return Object.values(networkInterfaces()).flat()
|
||||
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
.map(iface => iface.address)
|
||||
}
|
||||
|
||||
/**
|
||||
* One LAN-trust resolution for one invocation, sampled exactly once: the
|
||||
* machine's LAN IP literals when the effective bind is all-interfaces, and
|
||||
* the `trustedHosts` value built from them plus the explicit extras. The
|
||||
* single sample is deliberate — display must advertise only addresses the
|
||||
* fence was configured with, so the web-app row receives this same snapshot.
|
||||
* Derived entries are port-less IP literals: DNS rebinding needs an
|
||||
* attacker-controlled name, so an IP-literal Host is safe on any port, and
|
||||
* the bound port may be OS-assigned, unknowable pre-boot.
|
||||
* @param bindHost - the effective webserver bind host (CLI flag, else the composed row value).
|
||||
* @param extra - `--trusted-host` values, in argv order.
|
||||
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
|
||||
*/
|
||||
export function resolveLanTrust(
|
||||
bindHost: string | undefined,
|
||||
extra: readonly string[],
|
||||
): { lanAddresses: string[]; trustedHosts: string[] } {
|
||||
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/** The `dsh web` flag family, already parsed by the argument adapter. */
|
||||
export interface WebFlags {
|
||||
patches: string[]
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the web alias's flag patches over an already-composed profile tree.
|
||||
* Patches replace a row's whole config, so each patched row's composed values
|
||||
* are re-read and merged under the overrides.
|
||||
* @param rows - the composed row index from {@link composeProfile}.
|
||||
* @param flags - the parsed flag family.
|
||||
* @returns the flag patch list, in application order.
|
||||
*/
|
||||
function deriveWebFlagPatches(
|
||||
rows: ProfileRows,
|
||||
flags: WebFlags,
|
||||
): PatchOptions[] {
|
||||
const overrides = new Map<string, Record<string, unknown>>()
|
||||
const put = (entryId: string, key: string, value: unknown): void => {
|
||||
const bag = overrides.get(entryId) ?? {}
|
||||
bag[key] = value
|
||||
overrides.set(entryId, bag)
|
||||
}
|
||||
if (flags.host !== undefined) put('webserver', 'host', flags.host)
|
||||
if (flags.port !== undefined) put('webserver', 'port', flags.port)
|
||||
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
|
||||
if (trustedHosts.length > 0) {
|
||||
// Additive over the composed value: a cordis.patch.yml-configured fence
|
||||
// authority must survive the derived LAN literals and flag extras — a
|
||||
// silent drop of security-relevant fence configuration.
|
||||
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
|
||||
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
|
||||
}
|
||||
// mode and lanAddresses are launcher-derived on every boot (--dev also
|
||||
// inserts the client-hmr row), never pass-throughs of composed values.
|
||||
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
|
||||
put('web-runtime', 'lanAddresses', lanAddresses)
|
||||
// The agent-preset roots are patched by the shared profile boot: they are
|
||||
// an assembly fact of every dsh launcher, and `dsh run` composes agents
|
||||
// from the same roster this alias offers.
|
||||
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
|
||||
const composed = rows.get(id)
|
||||
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
|
||||
return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } }
|
||||
})
|
||||
if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] })
|
||||
return patches
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the composed Web runtime keeps its model- and shell-visible surface
|
||||
* context. The bundle schema defaults the field to true, so only an explicit
|
||||
* false suppresses both the bundle contributions and the launcher-owned
|
||||
* source-checkout section.
|
||||
* @param rows - the composed Web profile rows before launcher flag patches.
|
||||
* @returns true unless the web-runtime row explicitly disables surface context.
|
||||
*/
|
||||
export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
|
||||
return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the browser UI from the web profile. Host/port flags are passed
|
||||
* through only when given (absent, the composed profile values
|
||||
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
|
||||
* every boot. The URL line is printed by the web-app bundle's runtime row
|
||||
* after Loader settlement.
|
||||
* @param flags - the parsed `dsh web` flag family.
|
||||
* @param environment - this run's frozen environment snapshot.
|
||||
*/
|
||||
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
|
||||
await runProfile({
|
||||
environment,
|
||||
profile: 'web',
|
||||
patchFiles: flags.patches,
|
||||
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
|
||||
prepare: (ctx: Context, rows: ProfileRows) => {
|
||||
if (!webSurfaceContextEnabled(rows)) return
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
/** The base bundle whose package carries the Windows shell patch. */
|
||||
|
||||
@@ -21,22 +21,28 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes profile boots, one-shot runs, and the web alias', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
|
||||
it('routes profile boots and the web alias, handing the rest to the app', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] })
|
||||
expect(parse(['run', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' })
|
||||
expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' })
|
||||
expect(parse(['run', '--', '--profile', 'is', 'task', 'text']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
|
||||
expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] })
|
||||
})
|
||||
|
||||
it('ends the launcher flags at the first token it does not own', () => {
|
||||
// App flags, including its -h, and positionals reach the app verbatim.
|
||||
expect(parse(['--profile', 'tui', '--resume', 'abc']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] })
|
||||
expect(parse(['--profile', 'web', '-h']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] })
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] })
|
||||
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] })
|
||||
// Launcher flags placed after that boundary belong to the app too.
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] })
|
||||
})
|
||||
|
||||
it('routes the plugin pnpm forwarder', () => {
|
||||
@@ -44,8 +50,8 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'why', 'cordis']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['why', 'cordis'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'why', '@deepseek-ai/cordis']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['why', '@deepseek-ai/cordis'] })
|
||||
// Unknown pnpm flags forward verbatim.
|
||||
expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
|
||||
@@ -64,18 +70,12 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => {
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
expect(exitCode([])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run`
|
||||
expect(exitCode(['run'])).toBe(1)
|
||||
expect(exitCode(['run', ''])).toBe(1)
|
||||
expect(exitCode(['run', '--profile', '', 'task'])).toBe(1)
|
||||
expect(exitCode(['run', '--patch=', 'task'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
|
||||
expect(exitCode(['--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
|
||||
expect(exitCode(['--dump-config'])).toBe(1)
|
||||
@@ -87,21 +87,20 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--patch='])).toBe(1)
|
||||
// Boot-free dumps derive no flag patches; silently dropping the flags
|
||||
// would print a tree that differs from the same invocation's boot.
|
||||
// A dump never runs app command-line providers, so it cannot show what
|
||||
// those flags would decide; printing a tree that differs from the same
|
||||
// invocation's boot would mislead.
|
||||
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
|
||||
// A non-numeric port fails at the flag, not deep in the webserver schema.
|
||||
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1)
|
||||
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
|
||||
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
|
||||
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for help and version', () => {
|
||||
it('keeps its own help for an invocation with no app to hand it to', () => {
|
||||
expect(exitCode(['--help'])).toBe(0)
|
||||
expect(exitCode(['run', '--help'])).toBe(0)
|
||||
expect(exitCode(['-h'])).toBe(0)
|
||||
expect(exitCode(['--version'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
// The release version, including a prerelease such as 0.0.1-rc.1: `--version`
|
||||
// prints what this manifest carries, so no test may pin it to a literal.
|
||||
const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url))
|
||||
const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
|
||||
|
||||
async function runBuiltBin(
|
||||
@@ -129,8 +131,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
@@ -145,8 +147,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
|
||||
fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
@@ -194,8 +196,121 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
interface StartupFixture {
|
||||
home: string
|
||||
ready: string
|
||||
echo: string
|
||||
interrupt: string
|
||||
/** An always-running row's echo, used to observe that a user patch reload landed. */
|
||||
witness: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
|
||||
* a row that reads its app-owned service through a `!!js` config expression.
|
||||
* Both plugin modules resolve
|
||||
* `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
|
||||
* fallback, exactly as an installed out-of-tree bundle does.
|
||||
*/
|
||||
function createStartupFixture(): StartupFixture {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
|
||||
const profileDir = join(home, 'profiles', 'startup')
|
||||
// Written straight into the installed location: a row module resolves its
|
||||
// own imports from where it is installed, and only inside the profile does
|
||||
// Node's parent walk reach the installation fallback these plugins need.
|
||||
const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'startup.mjs'), [
|
||||
"import { Command } from 'commander'",
|
||||
"import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
|
||||
"export const name = 'fixture-startup'",
|
||||
"export const inject = ['cmdlineArgs']",
|
||||
'export function apply(ctx) {',
|
||||
" const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
|
||||
' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))',
|
||||
' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'waiting.mjs'), [
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'startup-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
|
||||
' ctx.effect(() => () => { clearInterval(heartbeat) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'witness.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'reload-witness'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: startup-fixture',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
|
||||
' inject: [fixtureStartup]',
|
||||
' config:',
|
||||
// Lazy interpolation runs only after the provider's service is injected.
|
||||
" generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
|
||||
' - id: fixture-startup',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
|
||||
' - id: reload-witness',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-startup-bundle',
|
||||
version: '0.0.0',
|
||||
type: 'module',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-startup',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
|
||||
return {
|
||||
home,
|
||||
ready: join(home, 'ready'),
|
||||
echo: join(home, 'config-echo'),
|
||||
interrupt: join(home, 'interrupt'),
|
||||
witness: join(home, 'witness'),
|
||||
}
|
||||
}
|
||||
|
||||
function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects inputs outside the current grammar', async () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
expect(bare.stdout).toBe('')
|
||||
@@ -203,46 +318,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const help = await runBuiltBin(['--help'])
|
||||
expect(help.code).toBe(0)
|
||||
expect(help.stdout).toContain('dsh --profile web')
|
||||
expect(help.stdout).toContain('dsh run "run the tests"')
|
||||
expect(help.stdout).toContain('dsh plugin --profile')
|
||||
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
|
||||
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(outsideGrammar)
|
||||
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
|
||||
const result = await runBuiltBin(removed)
|
||||
expect(result.code).toBe(1)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('prints run help without initializing the selected profile', async () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-'))
|
||||
const home = join(parent, 'not-created')
|
||||
it('routes help and usage errors without activating startup-dependent rows', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home })
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stdout).toContain('Usage: dsh run [options] <task...>')
|
||||
expect(existsSync(home)).toBe(false)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
const web = await runBuiltBin(['--profile', 'web', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(web.code).toBe(0)
|
||||
expect(web.stderr).toBe('')
|
||||
expect(web.stdout).toContain('Usage: dsh --profile web')
|
||||
expect(web.stdout).toContain('--port <port>')
|
||||
expect(web.stdout).not.toContain('dsh web: http://')
|
||||
|
||||
it('runs the default headless profile through the published run command', async () => {
|
||||
const apiKey = 'built-dsh-run-key'
|
||||
const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(headlessHelp.code).toBe(0)
|
||||
expect(headlessHelp.stderr).toBe('')
|
||||
expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
|
||||
|
||||
const missingTask = await runBuiltBin(['--profile', 'headless'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(missingTask.code).toBe(1)
|
||||
expect(missingTask.stderr).toContain('a task is required')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('runs the headless profile through its app-owned task positional', async () => {
|
||||
const apiKey = 'built-dsh-headless-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'published dsh run reached the mock',
|
||||
successText: 'published headless profile reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('published dsh run reached the mock')
|
||||
expect(result.stdout).toBe('published headless profile reached the mock')
|
||||
expect(result.stderr).toBe('')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
@@ -258,7 +390,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
|
||||
try {
|
||||
const result = await runBuiltBin(['--version'], {}, project)
|
||||
expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
|
||||
expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
@@ -318,9 +450,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// An HMR main-watcher initial scan that refreshes the include
|
||||
// mid-initial-apply deadlocks the failing apply's rollback against the
|
||||
// refresh drain: dsh exits 13 with no diagnostic instead of settling
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
// refresh drain: dsh exited 13 with no diagnostic instead of settling
|
||||
// ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
|
||||
try {
|
||||
@@ -337,9 +469,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
|
||||
it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
const child = startProfileLifecycle(fixture)
|
||||
const child = startProfileLifecycle(fixture, ['--unclaimed'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
requestProfileShutdown(child, fixture)
|
||||
@@ -405,6 +537,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('hands the app arguments to the profile, which applies them before its rows start', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
// The consumer started once, already carrying the flag value: the
|
||||
// launcher never saw --generation, and the app provider resolved it first.
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, [])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('keeps the app arguments across a user patch reload', async () => {
|
||||
// A live edit recomposes every row while the provider service remains
|
||||
// active, so each config expression reads the same invocation value (a
|
||||
// served port does not move back to its composed fallback).
|
||||
const fixture = createStartupFixture()
|
||||
const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
// Both rows: the waiting one carries the flag value, and the witness is
|
||||
// what a reload will re-mount. They start independently, so neither
|
||||
// marker implies the other.
|
||||
await waitForFile(fixture.ready)
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
// An edit to an unrelated row: the witness re-mounts, which is how this
|
||||
// test knows the whole tree was recomposed.
|
||||
rmSync(fixture.witness)
|
||||
writeFileSync(profilePatch, [
|
||||
'- id: reload-witness',
|
||||
' config:',
|
||||
' generation: reloaded',
|
||||
'',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it("prints the app's own help, starts none of its rows, and exits", async () => {
|
||||
const fixture = createStartupFixture()
|
||||
try {
|
||||
const result = await startStartupProfile(fixture, ['--help'])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('Usage: fixture')
|
||||
expect(result.stdout).toContain('--generation')
|
||||
expect(existsSync(fixture.ready)).toBe(false)
|
||||
} finally {
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
||||
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
||||
// checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
|
||||
@@ -491,18 +700,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
}, 30_000)
|
||||
|
||||
it('prints a headless profile with no Host, HTTP, or browser rows', async () => {
|
||||
it('prints the headless profile without Host or browser layers', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['--profile', 'headless', '--dump-default-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'")
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
|
||||
}, 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
@@ -543,16 +751,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
|
||||
it('shows the RL Web patch disabling runtime surface context', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['web', '--patch', coreWebOverlay, '--dump-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).toContain('surfaceContext: false')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github_repository": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"lib/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "dsh-github-repository-plugin-e2e-fixture",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"lib",
|
||||
"dsh-plugin.mjs",
|
||||
"dsh-plugin-assets"
|
||||
],
|
||||
"scripts": {
|
||||
"prepack": "tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": [
|
||||
"../skills"
|
||||
],
|
||||
"mcpServers": "./.mcp.json",
|
||||
"entry": "./lib/plugin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "0.0.1",
|
||||
"cordis": "4.0.0-rc.7",
|
||||
"tsdown": "0.22.2",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
|
||||
// The repository root's linter cannot resolve this independently installed
|
||||
// Git-package dependency; the package's prepack tsc validates the SDK types.
|
||||
/* oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-call, typescript/no-unsafe-member-access */
|
||||
const server = new McpServer({
|
||||
name: 'github-repository-plugin-e2e',
|
||||
version: '0.0.0',
|
||||
})
|
||||
|
||||
server.registerTool('proof', {
|
||||
description: 'Proves that an MCP server compiled from the exact GitHub repository package is active.',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [{ type: 'text', text: 'MCP_FROM_GITHUB_REPOSITORY' }],
|
||||
}))
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
const PROOF_TOOL_NAME = 'mcp__github_repository__proof'
|
||||
|
||||
interface TextBlock {
|
||||
readonly type: 'text'
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
interface ToolExecution {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
readonly isError: boolean
|
||||
readonly content: readonly TextBlock[]
|
||||
}
|
||||
|
||||
type PostDecision =
|
||||
| { readonly kind: 'accept'; readonly content?: readonly TextBlock[]; readonly value?: unknown; readonly additionalContexts?: readonly unknown[] }
|
||||
| { readonly kind: 'block'; readonly feedback: readonly TextBlock[] }
|
||||
|
||||
type PostListener = (
|
||||
execution: ToolExecution,
|
||||
result: ToolResult,
|
||||
next: () => Promise<PostDecision>,
|
||||
) => Promise<PostDecision>
|
||||
|
||||
type DshContext = Context & {
|
||||
on(event: 'tools/post-execute', listener: PostListener): () => void
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by the repository acceptance fixture. */
|
||||
export const name = 'github-repository-typescript-proof'
|
||||
|
||||
/** DSH tool registry required by the post-execute contribution. */
|
||||
export const inject = ['tools']
|
||||
|
||||
/**
|
||||
* Append a marker after the repository MCP proof tool succeeds.
|
||||
* @param ctx - trusted DSH Cordis context supplied to the repository package.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const dsh = ctx as DshContext
|
||||
dsh.on('tools/post-execute', async (execution, result, next): Promise<PostDecision> => {
|
||||
const decision = await next()
|
||||
if (execution.name !== PROOF_TOOL_NAME || result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) {
|
||||
return decision
|
||||
}
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [
|
||||
...(decision.content ?? result.content),
|
||||
{ type: 'text', text: 'TS_PLUGIN_FROM_GITHUB_REPOSITORY' },
|
||||
],
|
||||
...decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: github-source-proof
|
||||
description: Proves that dsh installed a private repository Plugin from an exact GitHub source.
|
||||
---
|
||||
|
||||
This skill exists only in the GitHub repository source fixture.
|
||||
2
apps/cli/tests/fixtures/never-dispose.mjs
vendored
2
apps/cli/tests/fixtures/never-dispose.mjs
vendored
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* Register a disposer that keeps process shutdown pending until it is forced.
|
||||
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - loader-mounted test plugin context.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const repositoryPluginPackage = join(repoRoot, 'packages/self-modification/repository-plugin')
|
||||
const releasePackageNames = new Set(globSync([
|
||||
'vendor/*/package.json',
|
||||
'packages/*/*/package.json',
|
||||
'apps/*/package.json',
|
||||
], { cwd: repoRoot }).map((filename) => {
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record<string, unknown>
|
||||
if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`)
|
||||
return manifest.name
|
||||
}))
|
||||
const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE
|
||||
const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1'
|
||||
const enabled = required || source !== undefined
|
||||
|
||||
interface PublishedPackageRegistry {
|
||||
url: string
|
||||
requests: string[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function publishedManifest(): Record<string, unknown> {
|
||||
const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
const version = manifest.version
|
||||
if (typeof version !== 'string') throw new Error('repository Plugin package version is missing')
|
||||
Reflect.deleteProperty(manifest, 'private')
|
||||
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
||||
const dependencies = manifest[field]
|
||||
if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue
|
||||
const entries = dependencies as Record<string, unknown>
|
||||
for (const name of Object.keys(entries)) {
|
||||
if (releasePackageNames.has(name)) {
|
||||
entries[name] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function startPublishedPackageRegistry(root: string): Promise<PublishedPackageRegistry> {
|
||||
const staging = join(root, 'published-repository-plugin')
|
||||
const artifacts = join(root, 'npm-registry-artifacts')
|
||||
mkdirSync(staging)
|
||||
mkdirSync(artifacts)
|
||||
cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true })
|
||||
for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) {
|
||||
cpSync(join(repositoryPluginPackage, filename), join(staging, filename))
|
||||
}
|
||||
cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE'))
|
||||
const manifest = publishedManifest()
|
||||
writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
|
||||
const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], {
|
||||
cwd: staging,
|
||||
reject: false,
|
||||
})
|
||||
if (packed.exitCode !== 0) {
|
||||
throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`)
|
||||
}
|
||||
const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz'))
|
||||
if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`)
|
||||
const tarball = readFileSync(join(artifacts, tarballs[0]!))
|
||||
const name = manifest.name as string
|
||||
const version = manifest.version as string
|
||||
const requests: string[] = []
|
||||
let registryUrl = ''
|
||||
const server = createServer((request, response) => {
|
||||
const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname)
|
||||
requests.push(`${request.method ?? 'GET'} ${path}`)
|
||||
if (path === `/${name}`) {
|
||||
const metadata = {
|
||||
name,
|
||||
'dist-tags': { latest: version },
|
||||
versions: {
|
||||
[version]: {
|
||||
...manifest,
|
||||
dist: {
|
||||
tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`,
|
||||
shasum: createHash('sha1').update(tarball).digest('hex'),
|
||||
integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify(metadata))
|
||||
return
|
||||
}
|
||||
if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) {
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': String(tarball.length),
|
||||
})
|
||||
response.end(tarball)
|
||||
return
|
||||
}
|
||||
response.writeHead(404, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: 'not found' }))
|
||||
})
|
||||
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('simulated npm registry did not expose a TCP address')
|
||||
registryUrl = `http://127.0.0.1:${address.port}/`
|
||||
return {
|
||||
url: registryUrl,
|
||||
requests,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => {
|
||||
it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => {
|
||||
expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true)
|
||||
expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch(
|
||||
/^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u,
|
||||
)
|
||||
|
||||
const apiKey = 'github-repository-plugin-e2e-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['tool_call_success', 'success'],
|
||||
apiKey,
|
||||
toolName: 'mcp__github_repository__proof',
|
||||
toolArguments: '{}',
|
||||
successText: 'trusted GitHub repository package reached dsh run',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-'))
|
||||
const registry = await startPublishedPackageRegistry(home)
|
||||
const npmrc = join(home, 'npmrc')
|
||||
writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`)
|
||||
const hostBin = join(home, 'host-bin')
|
||||
mkdirSync(hostBin)
|
||||
writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [
|
||||
'#!/bin/sh',
|
||||
'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2',
|
||||
'exit 91',
|
||||
'',
|
||||
].join('\n'), { mode: 0o700 })
|
||||
const patch = join(home, 'github-repository-plugin.cordis.patch.yml')
|
||||
writeFileSync(patch, [
|
||||
'- id: repository-plugins',
|
||||
' config:',
|
||||
' repositories:',
|
||||
` - ${JSON.stringify(source)}`,
|
||||
'- id: session-title-llm',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
try {
|
||||
const result = await execa(process.execPath, [
|
||||
dshBin,
|
||||
'run',
|
||||
'--patch',
|
||||
patch,
|
||||
'prove the private GitHub repository Plugin is active',
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
input: '',
|
||||
timeout: 180_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
// A warm runner cache could satisfy the exact tarball without
|
||||
// contacting this test's registry, which would stop proving the
|
||||
// unpublished package was installed through the simulated release.
|
||||
PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'),
|
||||
PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'),
|
||||
PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0)
|
||||
expect(result.stdout).toBe('trusted GitHub repository package reached dsh run')
|
||||
expect(server.requests).toHaveLength(2)
|
||||
const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}`
|
||||
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin')
|
||||
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz')
|
||||
const firstRequest = JSON.stringify(server.requests[0]!.body)
|
||||
const secondRequest = JSON.stringify(server.requests[1]!.body)
|
||||
expect(firstRequest, runtimeDiagnostic).toContain(
|
||||
'Proves that dsh installed a private repository Plugin from an exact GitHub source.',
|
||||
)
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof')
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
|
||||
const cacheRoot = join(home, 'cache', 'repository-plugins')
|
||||
const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory())
|
||||
expect(generations).toHaveLength(1)
|
||||
const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository')
|
||||
const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'dsh-github-repository-plugin-e2e-fixture',
|
||||
private: true,
|
||||
scripts: {
|
||||
prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare',
|
||||
},
|
||||
dsh: {
|
||||
skills: ['../skills'],
|
||||
mcpServers: './.mcp.json',
|
||||
entry: './lib/plugin.mjs',
|
||||
},
|
||||
dependencies: {
|
||||
'@modelcontextprotocol/sdk': '1.29.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@deepseek-ai/dsh-repository-plugin': '0.0.1',
|
||||
cordis: '4.0.0-rc.7',
|
||||
tsdown: '0.22.2',
|
||||
typescript: '6.0.3',
|
||||
},
|
||||
})
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8'))
|
||||
.toContain('This skill exists only in the GitHub repository source fixture.')
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs')
|
||||
expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(existsSync(join(installed, 'src'))).toBe(false)
|
||||
const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs'))
|
||||
expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true)
|
||||
const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8')
|
||||
expect(wrapper).toContain('dsh-repository-plugin')
|
||||
expect(wrapper).toContain('await import(manifest.entry)')
|
||||
expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
|
||||
} finally {
|
||||
await server.close()
|
||||
await registry.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 190_000)
|
||||
})
|
||||
@@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['run', 'never complete'],
|
||||
configArgs: ['--profile', 'headless', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts'
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
networkInterfaces: () => ({
|
||||
lo0: [
|
||||
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
|
||||
],
|
||||
en0: [
|
||||
{ family: 'IPv6', internal: false, address: 'fe80::1' },
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
|
||||
],
|
||||
en1: [
|
||||
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
|
||||
],
|
||||
utun0: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('resolveLanTrust', () => {
|
||||
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
|
||||
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
|
||||
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
|
||||
})
|
||||
|
||||
it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
|
||||
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
|
||||
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSurfaceContextEnabled', () => {
|
||||
it('defaults to enabled and honors an explicit complete-prompt disable', () => {
|
||||
expect(webSurfaceContextEnabled(new Map())).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { mode: 'production' } }],
|
||||
]))).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { surfaceContext: false } }],
|
||||
]))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,18 @@ import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -22,6 +25,15 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
||||
const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
/** The installation anchor whose dependency surface the preset module fallback mirrors. */
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
|
||||
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* You do have access to a mirror of common linux and python packages via apt and pip.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.`
|
||||
|
||||
/**
|
||||
* Boot the shipped Web composition, minus the rows that would bind a port,
|
||||
@@ -89,12 +101,26 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
return await boot('dsh-test', rootConfig, patches)
|
||||
return await boot('dsh-test', rootConfig, patches, (bootCtx) => {
|
||||
provideCmdline(bootCtx, { args: [], exit: () => {} })
|
||||
})
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
||||
|
||||
function enablePresetTool(composition: string, id: string): string {
|
||||
const row = ` - id: ${id}\n`
|
||||
const start = composition.indexOf(row)
|
||||
if (start < 0) throw new Error(`missing preset row ${id}`)
|
||||
const end = composition.indexOf('\n - id:', start + row.length)
|
||||
const disabled = composition.indexOf(' disabled: true\n', start)
|
||||
if (disabled < 0 || (end >= 0 && disabled > end)) {
|
||||
throw new Error(`preset row ${id} is not disabled`)
|
||||
}
|
||||
return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
|
||||
}
|
||||
|
||||
let ctx: Context
|
||||
beforeAll(async () => {
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
||||
@@ -134,7 +160,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
|
||||
'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
|
||||
'subagent', 'subagent_fork', 'task_kill',
|
||||
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
||||
'workflow', 'write',
|
||||
])
|
||||
@@ -143,14 +169,30 @@ describe('the shipped Web composition', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('composes exactly two tools from `minimal`', async () => {
|
||||
it('composes the exact RL prompt and two tools from `minimal`', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-minimal'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Exactly what the preset lists — nothing arrives from the host.
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections).toEqual([
|
||||
{ name: 'deployment:persona', text: MINIMAL_PROMPT },
|
||||
])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor'])
|
||||
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
|
||||
expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
|
||||
.toContain('Absolute path')
|
||||
const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact')
|
||||
expect(compact).toBeDefined()
|
||||
expect((compact as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
@@ -190,6 +232,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
||||
// And it keeps the standard agent's own tools rather than replacing them.
|
||||
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
|
||||
expect(tools).not.toContain('str_replace_editor')
|
||||
|
||||
// The preset's own authoring skill registers into ITS layer of the host
|
||||
// registry: the cordis agent's view carries it, the global view does not.
|
||||
@@ -216,9 +259,9 @@ describe('the shipped Web composition', () => {
|
||||
// the capabilities — so the assembly is what carries the claim.
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
|
||||
expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor')
|
||||
expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor')
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
||||
expect(sdk).toContain('str_replace_editor')
|
||||
expect(sdk).not.toContain('str_replace_editor')
|
||||
expect(sdk).toContain('web_search')
|
||||
|
||||
// The presentation is this agent's alone: the deployment default is
|
||||
@@ -339,18 +382,96 @@ describe('the shipped Web composition', () => {
|
||||
|
||||
expect(await readFile(path, 'utf8')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('gives each session its own persona', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-persona'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
describe('product subagent rows in user presets', () => {
|
||||
let productCtx: Context
|
||||
const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
|
||||
const userRoot = join(root, 'presets')
|
||||
const settingsFile = join(root, 'settings.yaml')
|
||||
const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
for (const id of ids) {
|
||||
let composition = standard
|
||||
if (id === 'products-codex' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-codex')
|
||||
}
|
||||
if (id === 'products-claude' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-claude-code')
|
||||
}
|
||||
const directory = join(userRoot, id)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'agent.cordis.yml'), composition)
|
||||
}
|
||||
productCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
},
|
||||
}])
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await productCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('composes none, either product, or both without changing the shared host registry', async () => {
|
||||
const expected = new Map<string, string[]>([
|
||||
['products-none', []],
|
||||
['products-codex', ['subagent_codex']],
|
||||
['products-claude', ['subagent_claude_code']],
|
||||
['products-both', ['subagent_claude_code', 'subagent_codex']],
|
||||
])
|
||||
expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([
|
||||
'spawn', 'fork', 'codex', 'claude-code',
|
||||
]))
|
||||
|
||||
for (const [id, productTools] of expected) {
|
||||
const handle = await productCtx.agents.create({
|
||||
sessionId: SessionId(`preset-${id}`),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const tools = toolNames(productCtx, handle.agent)
|
||||
expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
|
||||
.toEqual(productTools)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('applies a product-row edit only to later sessions on the preset', async () => {
|
||||
const preset = await productCtx.agentPresets.resolve('products-none')
|
||||
const original = await readFile(preset.path, 'utf8')
|
||||
const existing = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-existing'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text)
|
||||
.toContain('You are a coding agent powered by')
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex'))
|
||||
|
||||
const later = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-later'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
expect(toolNames(productCtx, later.agent)).toContain('subagent_codex')
|
||||
} finally {
|
||||
await later.dispose()
|
||||
}
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
await existing.dispose()
|
||||
await writeFile(preset.path, original)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -422,6 +543,59 @@ describe('a forked session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a delegated child', () => {
|
||||
it('runs on the composition its parent runs on', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-parent'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
// Exactly what an in-process subagent driver's creation window does.
|
||||
const child = await parent.agent.ctx.agents.create({
|
||||
sessionId: SessionId('preset-child'),
|
||||
meta: childSessionMeta(parent.agent, 1, 0),
|
||||
setup: (agentCtx) => {
|
||||
applyChildComposition(agentCtx, parent.agent, {})
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
// The shipped `standard` preset is the whole coding agent; an empty
|
||||
// child here is the defect, and equality alone would not catch it.
|
||||
expect(toolNames(ctx, child.agent)).toContain('bash')
|
||||
expect(child.agent.session.header.agentPreset).toBe('standard')
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('follows a parent that switched preset while blank', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-switch-parent'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal')
|
||||
const child = await parent.agent.ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-switch'),
|
||||
meta: childSessionMeta(parent.agent, 1, 0),
|
||||
setup: (agentCtx) => {
|
||||
applyChildComposition(agentCtx, parent.agent, {})
|
||||
},
|
||||
})
|
||||
try {
|
||||
// The live scope chain is the authority, not the parent's creation
|
||||
// header — which still names `standard`.
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
expect(child.agent.session.header.agentPreset).toBe('minimal')
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset on the shipped composition', () => {
|
||||
let authorCtx: Context
|
||||
let userRoot: string
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../packages/boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/cmdline"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/bundle/base"
|
||||
},
|
||||
@@ -47,6 +50,9 @@
|
||||
{
|
||||
"path": "../../packages/core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/environment"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/paths"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "apps/web"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./dist/*": "./dist/*",
|
||||
@@ -23,11 +30,12 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-group": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-group": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
|
||||
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
|
||||
expect(metadata).toContain('name: 我的模式')
|
||||
expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。')
|
||||
expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。')
|
||||
expect(metadata).not.toContain('order:')
|
||||
}, 60_000)
|
||||
|
||||
@@ -176,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
|
||||
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
|
||||
// Custom group gone with its only member; the shipped set stands.
|
||||
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
|
||||
// The custom group outlives its only member: the heading stays with the
|
||||
// creator entry so the place to author a preset never disappears.
|
||||
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
|
||||
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ 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 { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
@@ -78,6 +82,60 @@ function seedLog(): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one child so the assembled header snapshot exercises both action
|
||||
* contributors whose relative order is the product contract under test.
|
||||
* @param scaffold - the booted Web scaffold.
|
||||
* @param parentId - the seeded session whose header the browser opens.
|
||||
*/
|
||||
async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise<void> {
|
||||
const childId = sessionId('agent-preset-selection-child')
|
||||
const createdAt = 1784974100100
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: childId,
|
||||
createdAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: parentId,
|
||||
origin: 'subagent',
|
||||
delegationDepth: 1,
|
||||
agentPreset: 'minimal',
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(childId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: createdAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: createdAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Check the session-header action order.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: createdAt + 2,
|
||||
data: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot', provider: 'spawn', label: 'header order probe',
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: createdAt + 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset the host reports for the blank session the workspace connect
|
||||
* produced. Addressed by id rather than by scanning the serialized list: the
|
||||
@@ -120,7 +178,8 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// A resumed session runs what it was created with; seeding one that
|
||||
// records `minimal` is what makes the header label a claim about the
|
||||
// session rather than an echo of the current default.
|
||||
await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
|
||||
const seededId = await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
|
||||
await seedSubagent(scaffold, seededId)
|
||||
await seedWorkspaceSkill(scaffold.workspaceCwd)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -143,12 +202,12 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
|
||||
// The chip opens on the deployment default, by the name that preset
|
||||
// publishes rather than its directory name.
|
||||
expect(snapshot).toContain('标准模式')
|
||||
expect(snapshot).toContain('Standard mode')
|
||||
})
|
||||
|
||||
it('names every preset and what it is for', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu'))
|
||||
await page.getByRole('button', { name: '标准模式' }).click()
|
||||
await page.getByRole('button', { name: 'Standard mode' }).click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -157,15 +216,15 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
||||
// Every shipped preset, each with the sentence saying what it composes —
|
||||
// the id alone never said what a preset does.
|
||||
expect(snapshot).toContain('极简模式')
|
||||
expect(snapshot).toContain('创造模式')
|
||||
expect(snapshot).toContain('Minimal mode')
|
||||
expect(snapshot).toContain('Creator mode')
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
it('applies the staged pick to the blank session, and the host honors it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage'))
|
||||
await page.getByRole('button', { name: '标准模式' }).click()
|
||||
await page.getByRole('menuitem', { name: /极简模式/ }).click()
|
||||
await page.getByRole('button', { name: 'Standard mode' }).click()
|
||||
await page.getByRole('menuitem', { name: /Minimal mode/ }).click()
|
||||
|
||||
// The chip stages; the blank session the workspace connect produced is
|
||||
// what the stage lands on. The host's own answer is what comes back.
|
||||
@@ -197,8 +256,8 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// against its list row, so a row that never reprojected the first switch
|
||||
// answers "already standard" and sends nothing — and restores the catalog
|
||||
// instead of leaving the session reading the narrower composition.
|
||||
await page.getByRole('button', { name: '极简模式' }).click()
|
||||
await page.getByRole('menuitem', { name: /^标准模式/ }).first().click()
|
||||
await page.getByRole('button', { name: 'Minimal mode' }).click()
|
||||
await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
|
||||
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
await composer.fill('/')
|
||||
@@ -221,10 +280,12 @@ describe('web e2e: agent-preset selection', () => {
|
||||
const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('极简模式')
|
||||
expect(snapshot).toContain('Minimal mode')
|
||||
expect(snapshot).toContain('button "1 subagent"')
|
||||
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
|
||||
// Static chrome, not a control: the header can only report a composition
|
||||
// the host would refuse to change.
|
||||
expect(snapshot).not.toContain('button "极简模式"')
|
||||
expect(snapshot).not.toContain('button "Minimal mode"')
|
||||
})
|
||||
|
||||
it('drove every surface without a page error or a stream warning', () => {
|
||||
|
||||
@@ -70,7 +70,12 @@ let unmount: (() => void) | undefined
|
||||
export function installAssembledBootEnv(): void {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('dsh.locale', 'en')
|
||||
// The locale service derives its provisional locale from the browser and
|
||||
// takes an explicit choice only from Host settings, which this lane's
|
||||
// fixture transport does not serve; pinning the navigator is what selects
|
||||
// English here.
|
||||
Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
|
||||
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
@@ -88,6 +93,11 @@ export function installAssembledBootEnv(): void {
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
// Deleting the own properties uncovers jsdom's own accessors again
|
||||
// (Navigator declares both readonly, hence the erased receiver).
|
||||
const ownNavigator = navigator as unknown as Record<string, unknown>
|
||||
delete ownNavigator.languages
|
||||
delete ownNavigator.language
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.'
|
||||
|
||||
describe('core Web profile', () => {
|
||||
let scaffold: WebScaffold
|
||||
let agentHandle: AgentHandle
|
||||
|
||||
beforeAll(async () => {
|
||||
const systemPrompt = process.env.DSH_SYSTEM_PROMPT
|
||||
Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
|
||||
try {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
|
||||
} finally {
|
||||
if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt
|
||||
}
|
||||
agentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-smoke'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
|
||||
})
|
||||
|
||||
it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => {
|
||||
agentHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agentHandle.agent.whenIdle()
|
||||
|
||||
const requestHeader = agentHandle.agent.session.requestHeader()
|
||||
if (requestHeader === undefined) throw new Error('the core Web agent issued no model request')
|
||||
|
||||
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
|
||||
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
|
||||
const signal = new AbortController().signal
|
||||
const bash = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-bash-smoke'),
|
||||
name: 'bash',
|
||||
arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const editor = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-editor-smoke'),
|
||||
name: 'str_replace_editor',
|
||||
arguments: { command: 'view', path: seedPath },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
|
||||
const text = (result: typeof bash): string => result.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
|
||||
.trimEnd()
|
||||
|
||||
expect({
|
||||
prompt: requestHeader.system,
|
||||
tools: requestHeader.tools?.map(tool => tool.name),
|
||||
bash: text(bash),
|
||||
editor: text(editor),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bash": "CORE_WEB_BASH_OK",
|
||||
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
|
||||
1 CORE_WEB_EDITOR_OK
|
||||
2",
|
||||
"prompt": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
],
|
||||
}
|
||||
`)
|
||||
expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent))
|
||||
|
||||
const entries = [...scaffold.ctx.loader.entries()]
|
||||
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined()
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
|
||||
})
|
||||
|
||||
it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => {
|
||||
const previous = process.env.DSH_SYSTEM_PROMPT
|
||||
process.env.DSH_SYSTEM_PROMPT = 'RL prompt override'
|
||||
let overrideScaffold: WebScaffold | undefined
|
||||
let overrideAgent: AgentHandle | undefined
|
||||
try {
|
||||
overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
|
||||
overrideAgent = await overrideScaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-override'),
|
||||
meta: { cwd: overrideScaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
overrideAgent.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await overrideAgent.agent.whenIdle()
|
||||
expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override')
|
||||
} finally {
|
||||
try {
|
||||
await overrideAgent?.dispose()
|
||||
} finally {
|
||||
try {
|
||||
await overrideScaffold?.close()
|
||||
} finally {
|
||||
if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
|
||||
else process.env.DSH_SYSTEM_PROMPT = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
101
apps/web/tests/feedback-command.e2e.ts
Normal file
101
apps/web/tests/feedback-command.e2e.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Keyless assembled-browser coverage for the /feedback command over the
|
||||
// shipped Web bundles and the real host wire. The command plane settles
|
||||
// without a model turn: the host appends the log-only command/run +
|
||||
// feedback/record + command/done lifecycle, and the transcript renders the
|
||||
// acknowledgement — the recorded session id plus the session-sharing
|
||||
// disclosure — as a persistent command row. The scaffold mounts the shipped
|
||||
// telemetry row in FULL mode against a local dead endpoint (no record leaves
|
||||
// the process), so the golden pins the shipped default sentence
|
||||
// `Session sharing is enabled.`; the per-status sentences are pinned by the
|
||||
// package and OTel unit tests.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
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, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
// Discard port: loopback listener never binds, so FULL telemetry discloses
|
||||
// the shipped default policy without any record reaching a collector.
|
||||
const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs'
|
||||
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
|
||||
describe('web e2e: /feedback command acknowledgement', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
telemetryUrl: TELEMETRY_URL,
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
|
||||
})
|
||||
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 })
|
||||
// Fresh world: connecting a workspace births the blank session whose
|
||||
// live composer accepts the slash line.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('drives the recorded prompt to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive'))
|
||||
if (MODE !== 'record') {
|
||||
// Drift guard: the committed fixture must carry exactly the drive prompt.
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
// Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot
|
||||
// miss the turn/end that settles the recorded turn.
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command'))
|
||||
// The drive test settled the recorded turn: the transcript is active (a
|
||||
// command row does not render while a fresh session is still blank) and
|
||||
// the replayed reply is on screen.
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/feedback the diff view is unreadable')
|
||||
await input.press('Enter')
|
||||
// The command plane settles without a model turn: the ack row names the
|
||||
// recorded session and the mounted FULL backend's disclosure.
|
||||
await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,8 @@ 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 { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { REPO_ROOT } from './support.ts'
|
||||
|
||||
@@ -14,7 +14,7 @@ installAssembledBootEnv()
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const group = (await within(tree).findAllByText('fixture'))
|
||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
||||
@@ -33,7 +33,6 @@ async function openFixtureSession(): Promise<void> {
|
||||
}
|
||||
|
||||
it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => {
|
||||
localStorage.setItem('dsh.locale', 'zh')
|
||||
mountAssembledApp()
|
||||
await openFixtureSession()
|
||||
|
||||
@@ -73,24 +72,23 @@ it('renders the history image pair through the authorized attachment route and o
|
||||
fireEvent.doubleClick(frame)
|
||||
const lightbox = await screen.findByRole('dialog')
|
||||
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
|
||||
fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ }))
|
||||
fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts pasted images into the composer rail in order and removes them', async () => {
|
||||
localStorage.setItem('dsh.locale', 'zh')
|
||||
mountAssembledApp()
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
|
||||
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="在“fixture”中新建会话"]')
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
|
||||
if (start === null) throw new Error('fixture Workspace new-session action missing')
|
||||
fireEvent.click(start)
|
||||
|
||||
// Image-only send arming is pinned at package level (input-bar.spec.tsx);
|
||||
// this assembled lane pins the intake chain over the built graph.
|
||||
const textarea = await screen.findByPlaceholderText('描述你想要构建的内容', {}, { timeout: 10_000 })
|
||||
const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
@@ -102,7 +100,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
|
||||
// The rail is an accessible group holding the draft thumbnail (queried via
|
||||
// DOM: jsdom's a11y-visibility computation hides the composer subtree).
|
||||
const rail = await waitFor(() => {
|
||||
const el = document.querySelector('[role="group"][aria-label="待发送图片"]')
|
||||
const el = document.querySelector('[role="group"][aria-label="Pending images"]')
|
||||
if (el === null) throw new Error('attachment rail missing')
|
||||
return el
|
||||
}, { timeout: 5_000 })
|
||||
@@ -129,10 +127,10 @@ it('accepts pasted images into the composer rail in order and removes them', asy
|
||||
.toEqual(['pasted.png', 'second.png'])
|
||||
})
|
||||
|
||||
const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')]
|
||||
const remove = [...rail.querySelectorAll('button[aria-label^="Remove image"]')]
|
||||
if (remove.length !== 2) throw new Error('remove buttons missing')
|
||||
for (const button of remove) fireEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull()
|
||||
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
await page.getByRole('button', { name: 'Copy' }).first().focus()
|
||||
|
||||
115
apps/web/tests/minimal-preset.snapshot.ts
Normal file
115
apps/web/tests/minimal-preset.snapshot.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.'
|
||||
|
||||
describe('minimal agent preset', () => {
|
||||
let scaffold: WebScaffold
|
||||
let agentHandle: AgentHandle
|
||||
let disposeInjectedPrompt: () => void
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE })
|
||||
disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({
|
||||
name: 'test:injected-prompt',
|
||||
order: 999,
|
||||
text: 'THIS TEXT MUST NOT REACH THE MODEL.',
|
||||
})
|
||||
agentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('minimal-preset-smoke'),
|
||||
meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
try {
|
||||
disposeInjectedPrompt?.()
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed')
|
||||
})
|
||||
|
||||
it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
|
||||
agentHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agentHandle.agent.whenIdle()
|
||||
|
||||
const requestHeader = agentHandle.agent.session.requestHeader()
|
||||
if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
|
||||
|
||||
const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
|
||||
await mkdir(stateDir)
|
||||
const signal = new AbortController().signal
|
||||
await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('minimal-bash-state-setup'),
|
||||
name: 'bash',
|
||||
arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const bash = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('minimal-bash-state-read'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt')
|
||||
await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n')
|
||||
const editor = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('minimal-editor-smoke'),
|
||||
name: 'str_replace_editor',
|
||||
arguments: { command: 'view', path: seedPath },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
|
||||
const text = (result: typeof bash): string => result.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
|
||||
.trimEnd()
|
||||
|
||||
expect({
|
||||
prompt: requestHeader.system,
|
||||
tools: requestHeader.tools?.map(tool => tool.name),
|
||||
bash: text(bash),
|
||||
editor: text(editor),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bash": "PERSISTED:{{cwd}}/persistent-state",
|
||||
"editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines):
|
||||
1 MINIMAL_EDITOR_OK
|
||||
2",
|
||||
"prompt": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
],
|
||||
}
|
||||
`)
|
||||
expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name)))
|
||||
.toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name)))
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
|
||||
})
|
||||
})
|
||||
@@ -29,13 +29,12 @@ import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Page } from 'playwright'
|
||||
import { expect } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import Group from '@cordisjs/plugin-group'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import Group from '@deepseek-ai/cordis-plugin-group'
|
||||
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import {
|
||||
addHarnessSourceSection,
|
||||
assertEntriesLoaded,
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
@@ -65,6 +64,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 { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { REPO_ROOT, requireDist } from './support.ts'
|
||||
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
|
||||
@@ -245,12 +245,21 @@ export interface LaunchOptions {
|
||||
}
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
/**
|
||||
* Mount the shipped telemetry row in FULL mode against this exporter URL
|
||||
* instead of disabling it. Used to pin a real backend disclosure in
|
||||
* assembled coverage; point the URL at a local dead endpoint so no record
|
||||
* leaves the process.
|
||||
*/
|
||||
telemetryUrl?: string
|
||||
/**
|
||||
* Browse through a trusted non-loopback hostname that the browser resolves
|
||||
* to loopback (for example `*.localhost`). The test server stays bound to
|
||||
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
|
||||
*/
|
||||
remoteAuthority?: string
|
||||
/** Reuse an existing harness home so a second Host can verify user settings across origins. */
|
||||
harnessHome?: string
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -297,16 +306,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
|
||||
// paths at load, and an in-process boot must NEVER touch the developer's
|
||||
// real ~/.dsh document or credential file.
|
||||
const harnessHome = join(workspaceCwd, '.dsh-home')
|
||||
const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
|
||||
// Skill discovery is model-visible input, and its roots now resolve inside a
|
||||
// PRESET — a subtree this lane's include patches cannot reach, because the
|
||||
// roster mounts it directly per session rather than as a row of the booted
|
||||
// tree. The row's documented fallback is the environment, so pin that: the
|
||||
// whole scaffold lifetime, not just the boot, since presets mount when a
|
||||
// session is created. Without this a developer's real ~/.dsh/skills silently
|
||||
// enters replay requests and goldens while CI sees none.
|
||||
// enters replay requests and goldens while CI sees none. `DSH_HOME` follows
|
||||
// the resolved harness home so a scaffold sharing another's home — the
|
||||
// cross-port persistence scenario — pins the same roots the settings and
|
||||
// credentials rows were configured with.
|
||||
const skillRootEnvironment = {
|
||||
DSH_HOME: join(workspaceCwd, '.dsh-home'),
|
||||
DSH_HOME: harnessHome,
|
||||
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
|
||||
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
|
||||
}
|
||||
@@ -329,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
} catch (error) {
|
||||
const failures: unknown[] = [error]
|
||||
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
restoreSkillRootEnvironment()
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
|
||||
throw error
|
||||
}
|
||||
@@ -390,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
{ id: 'session-title-llm', disabled: true },
|
||||
// Fixture sessions must never leave the process: the shipped row defaults
|
||||
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
|
||||
// names in the ambient environment).
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
// names in the ambient environment). A scenario that pins a real backend
|
||||
// disclosure passes a local dead endpoint instead of disabling the row.
|
||||
options.telemetryUrl === undefined
|
||||
? { id: 'telemetry-otel', disabled: true }
|
||||
: { id: 'telemetry-otel', config: { exporter: { url: options.telemetryUrl }, shutdownTimeoutMillis: 1_000 } },
|
||||
{
|
||||
id: 'webserver',
|
||||
config: { host: '127.0.0.1', port: 0 },
|
||||
@@ -454,19 +470,26 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
ctx.baseUrl = pathToFileURL(profileDir).href + '/'
|
||||
// This direct Loader harness supplies the same root-path capability as app-boot.
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
// A host with no command line still provides one: the web bundle's startup
|
||||
// row releases the rows waiting on it, and with no arguments each starts on
|
||||
// the values this scaffold composed above. An exit request can only come
|
||||
// from a rejected argument, which a fixed empty list has none of.
|
||||
provideCmdline(ctx, {
|
||||
args: [],
|
||||
exit: (code) => {
|
||||
throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`)
|
||||
},
|
||||
})
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
// `cordis:group` beside it, exactly as `boot()` registers it: a group row is
|
||||
// how a preset gives one `isolate` realm to a provider and its consumers,
|
||||
// and a preset resolving package names from its own directory cannot reach
|
||||
// `@cordisjs/plugin-group` by name.
|
||||
// `@deepseek-ai/cordis-plugin-group` by name.
|
||||
ctx.loader.builtins.group = Group
|
||||
// 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
|
||||
if (surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, REPO_ROOT) })
|
||||
}
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(rootConfig).href, patches },
|
||||
|
||||
@@ -468,9 +468,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
if (done?.type !== 'command/done') throw new Error('feedback command did not settle')
|
||||
const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? []
|
||||
expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`)
|
||||
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
|
||||
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i)
|
||||
expect(extraLine).toBeUndefined()
|
||||
const userId = userLine?.slice('User: '.length)
|
||||
const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1]
|
||||
if (userId === undefined) throw new Error('feedback command omitted the user id')
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
|
||||
// section switching, both close paths), the Appearance preference row (the
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token +
|
||||
// browser theme-color metadata)
|
||||
// the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// the busy-state Enter preference, plus Permission as the persisted default
|
||||
// for subsequently created sessions.
|
||||
// the Language row and busy-state Enter preference (both Host-backed), 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.
|
||||
@@ -153,23 +152,24 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('flips the theme through the Appearance cubes and persists across reload', async () => {
|
||||
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
||||
interface ThemeState {
|
||||
attr: boolean
|
||||
background: string
|
||||
stored: string | null
|
||||
/** Pre-migration localStorage key; the Host-backed world never writes it. */
|
||||
legacy: string | null
|
||||
themeColor: string | null
|
||||
themeColorCount: number
|
||||
token: string
|
||||
}
|
||||
const readState = async (): Promise<ThemeState> => await page.evaluate(() => {
|
||||
const readState = async (target: Page = page): Promise<ThemeState> => await target.evaluate(() => {
|
||||
const metas = document.head.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
|
||||
const computed = getComputedStyle(document.body)
|
||||
return {
|
||||
attr: document.body.hasAttribute('data-ds-dark-theme'),
|
||||
background: computed.backgroundColor,
|
||||
stored: localStorage.getItem('dsh.theme'),
|
||||
legacy: localStorage.getItem('dsh.theme'),
|
||||
themeColor: metas[0]?.content ?? null,
|
||||
themeColorCount: metas.length,
|
||||
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
|
||||
@@ -193,27 +193,51 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const darkCube = dialog.getByRole('button', { name: '深色' })
|
||||
expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
|
||||
await darkCube.click()
|
||||
// The full cascade: pressed state, persisted preference, body attribute,
|
||||
// The full cascade: pressed state, Host-backed preference, body attribute,
|
||||
// alias token flip — all from one real user gesture.
|
||||
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
||||
const dark = await readState()
|
||||
expect(dark.attr).toBe(true)
|
||||
expect(dark.stored).toBe('dark')
|
||||
expect(dark.legacy).toBeNull()
|
||||
expect(dark.token).not.toBe(light.token)
|
||||
expectThemeColorSynchronized(dark)
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/ui-theme:\n\s+preference: dark/)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Reload: the preference survives boot (restore + presenter initial apply).
|
||||
// Reload: the preference survives the background Host read + presenter update.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
|
||||
const reloaded = await readState()
|
||||
expect(reloaded.attr).toBe(true)
|
||||
expect(reloaded.stored).toBe('dark')
|
||||
expect(reloaded.legacy).toBeNull()
|
||||
expectThemeColorSynchronized(reloaded)
|
||||
|
||||
// A second live Host binds another ephemeral port but shares the same
|
||||
// user-settings home. Its fresh origin has no theme localStorage and still
|
||||
// converges to dark before the settings dialog opens.
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.emulateMedia({ colorScheme: 'light' })
|
||||
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true)
|
||||
const secondState = await readState(secondPage)
|
||||
expect(secondState.legacy).toBeNull()
|
||||
expectThemeColorSynchronized(secondState)
|
||||
expect(secondTripwire.pageErrors).toEqual([])
|
||||
expect(secondTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await secondPage.close()
|
||||
await second.close()
|
||||
}
|
||||
|
||||
// `system` follows the emulated OS scheme (dark stays dark, light clears).
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
|
||||
@@ -233,7 +257,7 @@ 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 () => {
|
||||
it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -241,7 +265,9 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
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')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/ui-conversation:\n\s+busyEnter: steer/)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
@@ -251,15 +277,36 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await secondPage.getByRole('button', { name: '设置', exact: true }).click()
|
||||
await secondPage.getByRole('dialog', { name: '设置' })
|
||||
.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
expect(secondTripwire.pageErrors).toEqual([])
|
||||
expect(secondTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await secondPage.close()
|
||||
await second.close()
|
||||
}
|
||||
|
||||
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')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/ui-conversation:\n\s+busyEnter: queue/)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('switches the settings surface language and persists dsh.locale', async () => {
|
||||
it('persists the settings language across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const zhDialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -276,7 +323,9 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await enDialog.waitFor({ timeout: 10_000 })
|
||||
expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
|
||||
await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/locale:\n\s+preference: en/)
|
||||
// Reload keeps English; then restore zh so shared page state (and the
|
||||
// other specs' 设置-anchored selectors + goldens) see the default again.
|
||||
const warningStart = tripwire.warnings.length
|
||||
@@ -285,24 +334,47 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
const enTrigger = page.getByRole('button', { name: 'Settings' })
|
||||
await enTrigger.waitFor({ timeout: 10_000 })
|
||||
|
||||
// A Chinese browser on another port still receives the explicit English
|
||||
// preference from the shared Host settings document.
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await secondPage.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
await secondPage.getByRole('dialog', { name: 'Settings' })
|
||||
.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
expect(secondTripwire.pageErrors).toEqual([])
|
||||
expect(secondTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await secondPage.close()
|
||||
await second.close()
|
||||
}
|
||||
|
||||
await enTrigger.click()
|
||||
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
|
||||
await page.getByRole('menuitem', { name: '中文' }).click()
|
||||
await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/locale:\n\s+preference: zh/)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('opens an English browser in English without any stored preference', async () => {
|
||||
// A second page under a different browser language: nothing is persisted
|
||||
// for it, so the settings surface must follow the browser rather than the
|
||||
// product fallback the shared zh page shows.
|
||||
// A fresh Host home has no locale preference, so its surface follows the
|
||||
// browser rather than the product fallback.
|
||||
const fresh = await launchWebScaffold({})
|
||||
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
|
||||
const enTripwire = watchConsole(enPage)
|
||||
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
|
||||
try {
|
||||
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await enPage.goto(fresh.baseUrl, { waitUntil: 'load' })
|
||||
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
@@ -315,6 +387,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(enTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await enPage.close()
|
||||
await fresh.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ const EXPECTED_TOOLS = [
|
||||
'read',
|
||||
'send_message',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
'subagent_fork',
|
||||
'task_kill',
|
||||
|
||||
@@ -485,10 +485,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web',
|
||||
// Launcher flags come first: the first token the launcher does not own
|
||||
// starts the web app's own arguments.
|
||||
// Pin the in-browser picker: the shipped `-auto` row would resolve to
|
||||
// the native OS chooser on this bind, and no page can drive that.
|
||||
'--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
'--port', String(port),
|
||||
],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
@@ -30,7 +30,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 代码模式"':
|
||||
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
|
||||
- text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。
|
||||
- code: code
|
||||
- 'button "查看: 代码模式"':
|
||||
- img
|
||||
@@ -40,7 +40,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
@@ -50,7 +50,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
@@ -62,7 +62,7 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "设为默认: 我的模式"':
|
||||
- text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: my-agent
|
||||
- 'button "查看路径: 我的模式"':
|
||||
- img
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
@@ -30,7 +30,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 代码模式"':
|
||||
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
|
||||
- text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。
|
||||
- code: code
|
||||
- 'button "查看: 代码模式"':
|
||||
- img
|
||||
@@ -40,7 +40,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
@@ -50,7 +50,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
@@ -30,7 +30,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 代码模式"':
|
||||
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
|
||||
- text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。
|
||||
- code: code
|
||||
- 'button "查看: 代码模式"':
|
||||
- img
|
||||
@@ -40,7 +40,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
@@ -50,7 +50,7 @@
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
@@ -58,6 +58,7 @@
|
||||
- 'button "复制: 创造模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- heading "自定义" [level=3]
|
||||
- button "用「创造模式」创作自定义预设":
|
||||
- img
|
||||
- text: 用「创造模式」创作自定义预设
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Seeded turn" [disabled]
|
||||
- img
|
||||
- text: 极简模式
|
||||
- text: Minimal mode
|
||||
- button "1 subagent":
|
||||
- text: 1 subagent
|
||||
- img
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- button "Standard mode":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- img
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
- menu:
|
||||
- menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。":
|
||||
- text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.":
|
||||
- text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.
|
||||
- img
|
||||
- menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。"
|
||||
- menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。"
|
||||
- menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。"
|
||||
- menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
|
||||
- menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
|
||||
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Using ONE run_code program: run" [disabled]'
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use only Cordis tools. First" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
39
apps/web/tests/snapshots/feedback-command/ack.expected.md
Normal file
39
apps/web/tests/snapshots/feedback-command/ack.expected.md
Normal file
@@ -0,0 +1,39 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to reply with a single word. Let me comply.
|
||||
- paragraph: LIGHTHOUSE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."':
|
||||
- img
|
||||
- img
|
||||
- text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."
|
||||
- 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 "6% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
|
||||
17
apps/web/tests/snapshots/feedback-command/session.jsonl
Normal file
17
apps/web/tests/snapshots/feedback-command/session.jsonl
Normal file
@@ -0,0 +1,17 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"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],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the bash tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- button "Standard mode":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- button "Standard mode":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"}
|
||||
{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}","agentPreset":"minimal"}
|
||||
{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly MINIMAL_PRESET_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"MINIMAL_PRESET_REQUEST_OK"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_PRESET_REQUEST_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Plan a small change: add" [disabled]'
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
- text: Context injection AGENTS.md
|
||||
- img
|
||||
- text: permission preset read-only
|
||||
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]':
|
||||
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]':
|
||||
- img
|
||||
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}"
|
||||
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}"
|
||||
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
|
||||
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "/user-invoke-demo and confirm the fixtur" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "1 subagent":
|
||||
- text: 1 subagent
|
||||
- img
|
||||
@@ -41,7 +43,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 'button "Access mode, current: Custom"': Custom
|
||||
- button "6% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -18,6 +20,6 @@
|
||||
- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled]
|
||||
- button "Commands" [disabled]:
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write
|
||||
- 'button "Access mode, current: Custom" [disabled]': Custom
|
||||
- button "Stop generating"
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Begin your reply with the" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Begin your reply with the" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
await page.getByText('Standard mode', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
expect([
|
||||
Math.round(clickAreaBox!.x - treeBox!.x),
|
||||
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
|
||||
]).toEqual([5, 5])
|
||||
// Menu padding alone insets the rows now that the border is gone.
|
||||
]).toEqual([4, 4])
|
||||
await compareOrRefreshGolden(
|
||||
BRANCHLESS_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||||
|
||||
@@ -18,18 +18,17 @@ export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
export const ZH_BROWSER_LOCALE = 'zh-CN'
|
||||
|
||||
/**
|
||||
* Open the standard browser-test page with English selected before client
|
||||
* boot. This keeps role locators and goldens deterministic across localized
|
||||
* component migrations; the scenarios asserting the Chinese surface bypass
|
||||
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
|
||||
* Open the standard browser-test page advertising English before client boot.
|
||||
* This keeps role locators and goldens deterministic while leaving the Host
|
||||
* settings document free to override the provisional browser-derived locale;
|
||||
* scenarios asserting the Chinese surface advertise
|
||||
* {@link ZH_BROWSER_LOCALE} instead.
|
||||
* @param browser - Playwright browser owning the page.
|
||||
* @param height - Viewport height; width is fixed to the lane baseline.
|
||||
* @returns the initialized page.
|
||||
*/
|
||||
export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height } })
|
||||
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
|
||||
return page
|
||||
return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' })
|
||||
}
|
||||
|
||||
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"exclude": [
|
||||
"tests/scaffold.ts",
|
||||
"tests/scaffold-hermetic.e2e.ts",
|
||||
"tests/core-web-profile.snapshot.ts",
|
||||
"tests/minimal-preset.snapshot.ts",
|
||||
"tests/live-interactions.e2e.ts",
|
||||
"tests/question-composer.e2e.ts",
|
||||
"tests/approval-composer.e2e.ts",
|
||||
@@ -64,6 +64,7 @@
|
||||
"tests/agent-preset-selection.e2e.ts",
|
||||
"tests/agent-preset-authoring.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/feedback-command.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/produced-files.e2e.ts",
|
||||
"tests/produced-file-mentions.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user