Merge remote-tracking branch 'github/master' into feat/web-queue-steer-all
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts
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: f50f26ec5de54e094e17221f7cd355483483754e
|
||||
README.zh.md: 242e64a0c42064b9f0b7621e665e85fe44523fe5
|
||||
README.md: dd29f7fc03a783079ea3194de99589c1f545be5b
|
||||
README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc
|
||||
|
||||
@@ -9,11 +9,11 @@ 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 --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. |
|
||||
| `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 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. 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. `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`.
|
||||
|
||||
## Profiles
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
|
||||
| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 |
|
||||
| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
|
||||
## Profile
|
||||
|
||||
@@ -23,4 +23,4 @@ profile 目录包含一个 `package.json`(树外插件依赖,加上 profile
|
||||
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。
|
||||
|
||||
@@ -18,6 +18,12 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_llm
|
||||
plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"]
|
||||
cfg --> plugin_dsh_base_session
|
||||
plugin_dsh_base_typert["typert<br/>@deepseek-ai/dsh-typert-registry"]
|
||||
cfg --> plugin_dsh_base_typert
|
||||
plugin_dsh_base_typert_loader["typert-loader<br/>@deepseek-ai/dsh-typert-loader"]
|
||||
cfg --> plugin_dsh_base_typert_loader
|
||||
plugin_dsh_base_typert_gateway["typert-gateway<br/>@deepseek-ai/dsh-api-gateway"]
|
||||
cfg --> plugin_dsh_base_typert_gateway
|
||||
plugin_dsh_base_session_title["session-title<br/>@deepseek-ai/dsh-session-title"]
|
||||
cfg --> plugin_dsh_base_session_title
|
||||
plugin_dsh_base_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-message-llm"]
|
||||
@@ -26,6 +32,8 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_user_interaction
|
||||
plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
|
||||
cfg --> plugin_dsh_base_agent
|
||||
plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
|
||||
cfg --> plugin_dsh_base_agent_default_model
|
||||
plugin_dsh_base_tasks["tasks<br/>@deepseek-ai/dsh-tasks-local"]
|
||||
cfg --> plugin_dsh_base_tasks
|
||||
plugin_dsh_base_llm_retry["llm-retry<br/>@deepseek-ai/dsh-llm-retry"]
|
||||
@@ -74,10 +82,14 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_skill
|
||||
plugin_dsh_base_skill_local["skill-local<br/>@deepseek-ai/dsh-skill-local"]
|
||||
cfg --> plugin_dsh_base_skill_local
|
||||
plugin_dsh_base_skill_badge["skill-badge<br/>@deepseek-ai/dsh-skill-badge"]
|
||||
cfg --> plugin_dsh_base_skill_badge
|
||||
plugin_dsh_base_tool_skill["tool-skill<br/>@deepseek-ai/dsh-tool-skill"]
|
||||
cfg --> plugin_dsh_base_tool_skill
|
||||
plugin_dsh_base_commands["commands<br/>@deepseek-ai/dsh-commands"]
|
||||
cfg --> plugin_dsh_base_commands
|
||||
plugin_dsh_base_command_feedback["command-feedback<br/>@deepseek-ai/dsh-command-feedback"]
|
||||
cfg --> plugin_dsh_base_command_feedback
|
||||
plugin_dsh_base_goal["goal<br/>@deepseek-ai/dsh-goal"]
|
||||
cfg --> plugin_dsh_base_goal
|
||||
plugin_dsh_base_goal_session["goal-session<br/>@deepseek-ai/dsh-goal-session"]
|
||||
@@ -157,10 +169,14 @@ flowchart LR
|
||||
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
|
||||
| `llm` | `@deepseek-ai/dsh-llm` |
|
||||
| `session` | `@deepseek-ai/dsh-session` |
|
||||
| `typert` | `@deepseek-ai/dsh-typert-registry` |
|
||||
| `typert-loader` | `@deepseek-ai/dsh-typert-loader` |
|
||||
| `typert-gateway` | `@deepseek-ai/dsh-api-gateway` |
|
||||
| `session-title` | `@deepseek-ai/dsh-session-title` |
|
||||
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
|
||||
| `user-interaction` | `@deepseek-ai/dsh-user-interaction` |
|
||||
| `agent` | `@deepseek-ai/dsh-agent` |
|
||||
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
|
||||
| `tasks` | `@deepseek-ai/dsh-tasks-local` |
|
||||
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
|
||||
| `settings` | `@deepseek-ai/dsh-settings-local` |
|
||||
@@ -185,8 +201,10 @@ flowchart LR
|
||||
| `workspace-context` | `@deepseek-ai/dsh-workspace-context` |
|
||||
| `skill` | `@deepseek-ai/dsh-skill` |
|
||||
| `skill-local` | `@deepseek-ai/dsh-skill-local` |
|
||||
| `skill-badge` | `@deepseek-ai/dsh-skill-badge` |
|
||||
| `tool-skill` | `@deepseek-ai/dsh-tool-skill` |
|
||||
| `commands` | `@deepseek-ai/dsh-commands` |
|
||||
| `command-feedback` | `@deepseek-ai/dsh-command-feedback` |
|
||||
| `goal` | `@deepseek-ai/dsh-goal` |
|
||||
| `goal-session` | `@deepseek-ai/dsh-goal-session` |
|
||||
| `command-goal` | `@deepseek-ai/dsh-command-goal` |
|
||||
|
||||
@@ -36,10 +36,14 @@
|
||||
"node-addon-require-builtin": "^0.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend-static": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "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: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026
|
||||
README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf
|
||||
README.md: bb3e1d77f00e03f6a21f4da3c04994f208e84293
|
||||
README.zh.md: 3802f940ef6663ec215cf09293a9edda5ea4c512
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Profile boot
|
||||
|
||||
@@ -10,9 +10,9 @@ This reference defines the profile, web-alias, plugin-management, and config-dum
|
||||
|
||||
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 + web-app + headless). 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). 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>`.
|
||||
|
||||
A positional task (`dsh --profile headless "run the tests"`) requires the composition to mount the one-shot runner row (`headless-runner`); the launcher patches the task text into that row, the runner drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser.
|
||||
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.
|
||||
|
||||
Inspect the composed tree without booting it:
|
||||
|
||||
@@ -21,7 +21,13 @@ 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 provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
`--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.
|
||||
|
||||
## Plugin management
|
||||
|
||||
@@ -59,11 +65,11 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy
|
||||
|
||||
## 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 live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
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.
|
||||
|
||||
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/cordis/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.
|
||||
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.
|
||||
|
||||
## Source launcher
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[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 启动
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
组合包名称先从 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 + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
位置参数任务(`dsh --profile headless "run the tests"`)要求组合挂载一次性运行器行(`headless-runner`);启动器把任务文本 patch 进该行,运行器通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。
|
||||
Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile <name> "<task>"`,而不会触发该行原始的必填字段错误。
|
||||
|
||||
可在不启动的情况下检查组合出的配置树:
|
||||
|
||||
@@ -21,7 +21,13 @@ 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。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
`--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 写入任何内容,也不会打开监听端口。
|
||||
|
||||
## 插件管理
|
||||
|
||||
@@ -55,15 +61,15 @@ dsh web --dump-config
|
||||
|
||||
`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_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
基础组合包挂载原生 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` 后,该工具才可用。
|
||||
|
||||
会话事件默认作为 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/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
空 `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(智能体)沙箱之外的受信任可执行代码。
|
||||
|
||||
## 源码启动器
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Commander adapter for the `dsh` command-line entry. The default command
|
||||
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
|
||||
* overlays and a positional task (one-shot mode for profiles mounting the
|
||||
* headless runner). `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.
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
@@ -16,8 +16,16 @@ interface ProfileInvocation {
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
/** Positional task text joined by spaces; non-empty only for one-shot runs. */
|
||||
task?: 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
|
||||
}
|
||||
|
||||
/** Print a composed profile tree and exit without booting. */
|
||||
@@ -54,7 +62,7 @@ interface PluginInvocation {
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
|
||||
export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
@@ -68,6 +76,12 @@ interface WebOptions {
|
||||
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.
|
||||
@@ -90,19 +104,19 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
|
||||
.addHelpText('after', `
|
||||
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 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
|
||||
`)
|
||||
.exitOverride()
|
||||
.enablePositionalOptions()
|
||||
.argument('[task...]', 'one-shot task text for profiles mounting the headless runner')
|
||||
.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((task: string[], options: {
|
||||
.action((options: {
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
@@ -116,7 +130,6 @@ Examples:
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task')
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
@@ -124,12 +137,7 @@ Examples:
|
||||
resolved = { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
return
|
||||
}
|
||||
resolved = {
|
||||
mode: 'profile',
|
||||
profile,
|
||||
patches,
|
||||
...task.length > 0 ? { task: task.join(' ') } : {},
|
||||
}
|
||||
resolved = { mode: 'profile', profile, patches }
|
||||
})
|
||||
|
||||
/** Reject parent options that crossed a subcommand boundary. */
|
||||
@@ -146,6 +154,22 @@ Examples:
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
web
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { parseDshArgs } from './args.ts'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
@@ -24,22 +24,31 @@ function readVersion(): string {
|
||||
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
|
||||
}
|
||||
|
||||
loadEnv('dsh')
|
||||
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
|
||||
|
||||
switch (invocation.mode) {
|
||||
case 'profile': {
|
||||
const { runProfile } = await import('./profile-boot.ts')
|
||||
await runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
...invocation.task !== undefined && { task: invocation.task },
|
||||
})
|
||||
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)
|
||||
await runWeb(invocation, loadLayeredEnv('dsh'))
|
||||
break
|
||||
}
|
||||
case 'plugin': {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Config-dump entry for `dsh --profile <name> --dump-config`: compose the
|
||||
* profile's patch layers through the include plugin's patch algorithm without
|
||||
* booting or evaluating `!!js`, with one provenance layer per bundle, the
|
||||
* booting or evaluating `!!js`, with one source layer per bundle, the
|
||||
* profile's own patch file, and each `--patch` overlay.
|
||||
* @module @deepseek-ai/dsh/dump-config
|
||||
*/
|
||||
@@ -20,7 +20,7 @@ const NAME = 'dsh'
|
||||
|
||||
/* v8 ignore start -- built-bin acceptance drives this boot-free dispatch */
|
||||
/**
|
||||
* Print a profile composition with provenance comments.
|
||||
* Print a profile composition with comments naming each source file and patch layer.
|
||||
* @param profile - the profile name.
|
||||
* @param defaultOnly - omit the profile's user layer and `--patch` overlays
|
||||
* (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import { FiberState, type Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import {
|
||||
boot,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
@@ -46,7 +47,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 positional task requires and configures. */
|
||||
/** The one-shot runner row a `dsh run` task requires and configures. */
|
||||
const HEADLESS_ROW_ID = 'headless-runner'
|
||||
|
||||
/** The empty root entry list every profile tree patches over. */
|
||||
@@ -159,10 +160,17 @@ export interface RunProfileOptions {
|
||||
patchFiles: readonly string[]
|
||||
/** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
|
||||
deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[]
|
||||
/** One-shot task text; requires the composition to mount the headless runner row. */
|
||||
/** `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
|
||||
}
|
||||
|
||||
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
|
||||
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
|
||||
if (!signal.aborted) throw error
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,17 +195,22 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// error naming no fix.
|
||||
throw new Error(
|
||||
`dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
|
||||
+ `dsh --profile ${options.profile} "<task>"`,
|
||||
+ `dsh run --profile ${options.profile} "<task>"`,
|
||||
)
|
||||
}
|
||||
|
||||
const app: { current?: Context } = {}
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
const signalShutdown = new AbortController()
|
||||
const interrupt = (code: number): void => {
|
||||
signalShutdown.abort()
|
||||
shutdown.interrupt(code)
|
||||
}
|
||||
// Signals own teardown throughout the startup window, not only after boot()
|
||||
// settles: an inserted front door can publish readiness before sibling rows
|
||||
// finish mounting.
|
||||
process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) })
|
||||
process.on('SIGINT', () => { shutdown.interrupt(130) })
|
||||
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
|
||||
process.on('SIGINT', () => { interrupt(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
})
|
||||
@@ -226,6 +239,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// application must not mutate the objects later reloads recompose from.
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (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.
|
||||
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
|
||||
if (options.task !== undefined) {
|
||||
const io: HeadlessIo = {
|
||||
stdout: process.stdout,
|
||||
@@ -237,33 +253,41 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
await options.prepare?.(hostCtx, composed.rows)
|
||||
})
|
||||
app.current = ctx
|
||||
// A surface can dispose the whole tree while startup was still in flight
|
||||
// (early SIGTERM); the Loader service goes with it and there is nothing to
|
||||
// keep live.
|
||||
if (watchProfilePatch && ctx.get('loader') !== undefined) {
|
||||
// Config-only HMR for the live profile patch layer: the web bundle
|
||||
// disables the shared module-reload `hmr` row (its reload lifecycle is
|
||||
// untested), so when the composition leaves no HMR service, mount a
|
||||
// watch-only instance with no module roots — cordis.patch.yml edits stay
|
||||
// live on every long-lived surface. A silent skip would break the
|
||||
// documented hot-reload contract. HMR injects the timer service, which a
|
||||
// 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' })
|
||||
// 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
|
||||
// liveness; the local signal fact distinguishes that expected exit race
|
||||
// from a real HMR error.
|
||||
if (watchProfilePatch
|
||||
&& !signalShutdown.signal.aborted
|
||||
&& ctx.fiber.state === FiberState.ACTIVE
|
||||
&& ctx.get('loader') !== undefined) {
|
||||
try {
|
||||
// Config-only HMR for the live profile patch layer: the web bundle
|
||||
// disables the shared module-reload `hmr` row (its reload lifecycle is
|
||||
// untested), so when the composition leaves no HMR service, mount a
|
||||
// watch-only instance with no module roots — cordis.patch.yml edits stay
|
||||
// live on every long-lived surface. A silent skip would break the
|
||||
// documented hot-reload contract. HMR injects the timer service, which a
|
||||
// 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: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
}
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: composed.profile.patchPath,
|
||||
compose: composeLive,
|
||||
})
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: homePatchPath(),
|
||||
compose: composeLive,
|
||||
})
|
||||
} catch (error) {
|
||||
suppressSignalShutdownError(signalShutdown.signal, error)
|
||||
}
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: composed.profile.patchPath,
|
||||
compose: composeLive,
|
||||
})
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: homePatchPath(),
|
||||
compose: composeLive,
|
||||
})
|
||||
}
|
||||
return { ctx, shutdown }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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))
|
||||
@@ -124,9 +125,11 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
|
||||
* 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): Promise<void> {
|
||||
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
|
||||
await runProfile({
|
||||
environment,
|
||||
profile: 'web',
|
||||
patchFiles: flags.patches,
|
||||
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
|
||||
|
||||
@@ -21,12 +21,16 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes profile boots, one-shot tasks, and the web alias', () => {
|
||||
it('routes profile boots, one-shot runs, and the web alias', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
|
||||
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' })
|
||||
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'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
@@ -60,11 +64,18 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
it('rejects missing profile, flags outside the current grammar, 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) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
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(['--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
|
||||
expect(exitCode(['--dump-config'])).toBe(1)
|
||||
@@ -90,6 +101,7 @@ describe('parseDshArgs', () => {
|
||||
|
||||
it('exits 0 for help and version', () => {
|
||||
expect(exitCode(['--help'])).toBe(0)
|
||||
expect(exitCode(['run', '--help'])).toBe(0)
|
||||
expect(exitCode(['--version'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi
|
||||
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
env: Readonly<Record<string, string | undefined>> = {},
|
||||
cwd?: string,
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const childEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env })
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
)
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
env: childEnv,
|
||||
extendEnv: false,
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -41,6 +49,7 @@ interface ProfileLifecycleFixture {
|
||||
ready: string
|
||||
settled: string
|
||||
disposed: string
|
||||
interrupt: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
const ready = join(home, 'ready')
|
||||
const settled = join(home, 'settled')
|
||||
const disposed = join(home, 'disposed')
|
||||
const interrupt = join(home, 'interrupt')
|
||||
const bundleDir = join(home, 'lifecycle-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'profile-lifecycle-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let active = true',
|
||||
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
|
||||
' const heartbeat = setInterval(() => {}, 1000)',
|
||||
' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
' // Echo the mounted generation so the hot-reload e2e can assert both an',
|
||||
' // applied override and its removal reverting to this bundle default.',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
@@ -110,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
|
||||
writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
|
||||
}
|
||||
return { home, ready, settled, disposed }
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
@@ -123,12 +139,63 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_SETTLED_FILE: fixture.settled,
|
||||
RAW_DISPOSED_FILE: fixture.disposed,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
return
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
|
||||
function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
const pluginFile = join(project, 'environment-probe.mjs')
|
||||
writeFileSync(pluginFile, [
|
||||
"export const name = 'environment-probe'",
|
||||
"export const inject = ['llm']",
|
||||
'export function apply(ctx) {',
|
||||
' void ctx.loader.await().then(async () => {',
|
||||
" let text = ''",
|
||||
' for await (const chunk of ctx.llm.stream({',
|
||||
" provider: 'deepseek-official',",
|
||||
" model: 'deepseek-v4-flash',",
|
||||
' messages: [],',
|
||||
' maxTokens: 32,',
|
||||
' })) {',
|
||||
" if (chunk.type === 'text-delta') text += chunk.text",
|
||||
' }',
|
||||
' process.stdout.write(`${text}\\n`)',
|
||||
" if (process.platform === 'win32') process.emit('SIGTERM')",
|
||||
" else process.kill(process.pid, 'SIGTERM')",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const profileDir = join(home, 'profiles', 'environment-probe')
|
||||
mkdirSync(profileDir, { recursive: true })
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-environment-probe',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: environment-probe',
|
||||
` name: ${pathToFileURL(pluginFile).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
it('requires --profile and rejects inputs outside the current grammar', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
expect(bare.stdout).toBe('')
|
||||
@@ -136,14 +203,67 @@ 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 removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) {
|
||||
const result = await runBuiltBin(removed)
|
||||
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(outsideGrammar)
|
||||
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')
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the default headless profile through the published run command', async () => {
|
||||
const apiKey = 'built-dsh-run-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'published dsh run reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', '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.stderr).toBe('')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
|
||||
} finally {
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('does not load a project environment for --version', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
|
||||
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: '' })
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
|
||||
try {
|
||||
@@ -156,10 +276,51 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('uses the Harness-home environment and managed credential through the published entry', async () => {
|
||||
const apiKey = 'built-home-layer-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'home environment reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
|
||||
writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
|
||||
writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
|
||||
createEnvironmentProbeProfile(home, project)
|
||||
try {
|
||||
const result = await runBuiltBin(
|
||||
['--profile', 'environment-probe'],
|
||||
{
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: undefined,
|
||||
DEEPSEEK_BASE_URL: undefined,
|
||||
},
|
||||
project,
|
||||
)
|
||||
expect(
|
||||
result.code,
|
||||
`${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
|
||||
).toBe(0)
|
||||
expect(result.stdout).toBe('home environment reached the mock')
|
||||
expect(result.stdout).not.toContain(apiKey)
|
||||
expect(result.stderr).not.toContain(apiKey)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
expect(server.requests[0]?.path).toBe('/chat/completions')
|
||||
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
|
||||
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
|
||||
} finally {
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// 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
|
||||
// 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
|
||||
// ([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 {
|
||||
@@ -181,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const child = startProfileLifecycle(fixture)
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
@@ -233,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(configFile, 'utf8')).toBe('home')
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
@@ -330,6 +491,20 @@ 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 () => {
|
||||
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.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
|
||||
}, 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
// Auto-init the web profile first, then write its user layer.
|
||||
const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
|
||||
@@ -364,7 +539,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain('provider: configured-provider')
|
||||
expect(stdout).not.toContain('personal-provider')
|
||||
// Both layers patched the row; provenance lists them in application order.
|
||||
// Both layers patched the row; the comment lists them in application order.
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
|
||||
176
apps/cli/tests/dsh-badge.snapshot.ts
Normal file
176
apps/cli/tests/dsh-badge.snapshot.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
const binScript = fileURLToPath(new URL('./fixtures/dsh-badge/snapshot.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('./fixtures/dsh-badge/cordis.yml', import.meta.url))
|
||||
const defaultConfigPath = fileURLToPath(new URL('./fixtures/dsh-badge/default.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const badgeAssetsPath = fileURLToPath(new URL('../../../packages/skill/skill-badge/assets/', import.meta.url))
|
||||
|
||||
describe('dsh badge assembled snapshot', () => {
|
||||
it('advertises and loads the opt-in bundled skill through the shipped app', async () => {
|
||||
const disabled = await runLoaderSmoke({
|
||||
label: 'disabled dsh badge skill snapshot',
|
||||
tempDirPrefix: 'headless-snapshot-dsh-badge-disabled-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
configPath: defaultConfigPath,
|
||||
tsconfigPath,
|
||||
})
|
||||
const enabled = await runLoaderSmoke({
|
||||
label: 'dsh badge skill snapshot',
|
||||
tempDirPrefix: 'headless-snapshot-dsh-badge-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
})
|
||||
const disabledSnapshot = JSON.parse(disabled.stdout) as unknown
|
||||
const enabledSnapshot = JSON.parse(
|
||||
enabled.stdout.replaceAll(badgeAssetsPath, '{{badgeAssetsPath}}'),
|
||||
) as unknown
|
||||
|
||||
expect(disabled.stderr).toBe('')
|
||||
expect(enabled.stderr).toBe('')
|
||||
expect(disabledSnapshot).toMatchInlineSnapshot(`
|
||||
{
|
||||
"catalog": null,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"text": "Error: skill "dsh-badge" is unknown or no longer available",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"error": {
|
||||
"message": "skill "dsh-badge" is unknown or no longer available",
|
||||
},
|
||||
"isError": true,
|
||||
},
|
||||
"summary": null,
|
||||
}
|
||||
`)
|
||||
expect(enabledSnapshot).toMatchInlineSnapshot(`
|
||||
{
|
||||
"catalog": [
|
||||
{
|
||||
"text": "<system-reminder>
|
||||
A skill is a reusable set of task-specific instructions. The following skills are available in this session:
|
||||
|
||||
<available_skills>
|
||||
- \`dsh-badge\`: Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.
|
||||
</available_skills>
|
||||
|
||||
If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.
|
||||
A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill.
|
||||
</system-reminder>",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"text": "<skill_content name="dsh-badge">
|
||||
<skill_resources>
|
||||
Base directory for this skill: {{badgeAssetsPath}}
|
||||
Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.
|
||||
</skill_resources>
|
||||
|
||||
<skill_instructions>
|
||||
# dsh Badge
|
||||
|
||||
Add the official “powered by dsh” badge without recreating or restyling it.
|
||||
|
||||
## Assets
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
|
||||
\`\`\`markdown
|
||||

|
||||
\`\`\`
|
||||
|
||||
## Usage rules
|
||||
|
||||
- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
|
||||
- For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge.
|
||||
- Preserve the badge's 121×20 dimensions and aspect ratio.
|
||||
- Place the badge at the end of the attributed document or section unless the user specifies another position.
|
||||
- Do not substitute another color, logo, label, or project URL.
|
||||
|
||||
</skill_instructions>
|
||||
</skill_content>",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"isError": false,
|
||||
"value": {
|
||||
"content": "# dsh Badge
|
||||
|
||||
Add the official “powered by dsh” badge without recreating or restyling it.
|
||||
|
||||
## Assets
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
|
||||
\`\`\`markdown
|
||||

|
||||
\`\`\`
|
||||
|
||||
## Usage rules
|
||||
|
||||
- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
|
||||
- For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge.
|
||||
- Preserve the badge's 121×20 dimensions and aspect ratio.
|
||||
- Place the badge at the end of the attributed document or section unless the user specifies another position.
|
||||
- Do not substitute another color, logo, label, or project URL.
|
||||
",
|
||||
"name": "dsh-badge",
|
||||
"provider": "dsh-badge",
|
||||
"resourceBase": {
|
||||
"kind": "directory",
|
||||
"path": "{{badgeAssetsPath}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
"summary": {
|
||||
"description": "Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.",
|
||||
"invocation": {
|
||||
"modelInvocable": true,
|
||||
"userInvocable": true,
|
||||
},
|
||||
"name": "dsh-badge",
|
||||
"provider": "dsh-badge",
|
||||
"resourceBase": {
|
||||
"kind": "directory",
|
||||
"path": "{{badgeAssetsPath}}",
|
||||
},
|
||||
"source": "bundled",
|
||||
},
|
||||
}
|
||||
`)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS * 2)
|
||||
})
|
||||
9
apps/cli/tests/fixtures/dsh-badge/cordis.yml
vendored
Normal file
9
apps/cli/tests/fixtures/dsh-badge/cordis.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
- id: skill-badge
|
||||
disabled: false
|
||||
|
||||
- id: skill-local
|
||||
config:
|
||||
watch: false
|
||||
|
||||
- id: telemetry-otel
|
||||
disabled: true
|
||||
6
apps/cli/tests/fixtures/dsh-badge/default.cordis.yml
vendored
Normal file
6
apps/cli/tests/fixtures/dsh-badge/default.cordis.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
- id: skill-local
|
||||
config:
|
||||
watch: false
|
||||
|
||||
- id: telemetry-otel
|
||||
disabled: true
|
||||
56
apps/cli/tests/fixtures/dsh-badge/snapshot.ts
vendored
Normal file
56
apps/cli/tests/fixtures/dsh-badge/snapshot.ts
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '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'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const overlayPath = process.argv[2]
|
||||
if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path')
|
||||
const rootConfigPath = fileURLToPath(new URL('../../../../../packages/bundle/base/tests/fixtures/root.cordis.yml', import.meta.url))
|
||||
const basePatchPath = fileURLToPath(new URL('../../../../../packages/bundle/base/cordis.patch.yml', import.meta.url))
|
||||
const ctx = await boot('dsh-badge-snapshot', rootConfigPath, [
|
||||
...loadOverlayPatches('dsh-badge-snapshot', basePatchPath),
|
||||
...loadOverlayPatches('dsh-badge-snapshot', overlayPath),
|
||||
])
|
||||
|
||||
try {
|
||||
const agentId = SessionId('dsh-badge-snapshot')
|
||||
const session = ctx.sessions.create(agentId, { meta: { cwd: process.cwd() } })
|
||||
const agent: Agent = {
|
||||
ctx: new Context(),
|
||||
id: agentId,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => { throw new Error('dsh-badge snapshot must receive the catalog at the step boundary') },
|
||||
cancel: () => {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
{ messages: [], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
const catalog = decision.kind === 'enter'
|
||||
? decision.messages.find(message => message.role === 'user'
|
||||
&& message.source.kind === 'skill-catalog')?.content
|
||||
: undefined
|
||||
const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge')
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('dsh-badge-snapshot'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
process.stdout.write(`${JSON.stringify({ catalog: catalog ?? null, summary: summary ?? null, result })}\n`)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
10
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json
vendored
Normal file
10
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github_repository": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"lib/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json
vendored
Normal file
30
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
19
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts
vendored
Normal file
19
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
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())
|
||||
59
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts
vendored
Normal file
59
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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 },
|
||||
}
|
||||
})
|
||||
}
|
||||
13
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json
vendored
Normal file
13
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
6
apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md
vendored
Normal file
6
apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
1
apps/cli/tests/fixtures/never-dispose.mjs
vendored
1
apps/cli/tests/fixtures/never-dispose.mjs
vendored
@@ -8,6 +8,7 @@ import { existsSync } from 'node:fs'
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
process.stderr.write('dsh-test: never-dispose ready\n')
|
||||
ctx.effect(() => async () => {
|
||||
clearInterval(keepAlive)
|
||||
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
|
||||
|
||||
256
apps/cli/tests/github-repository-plugin.built.e2e.ts
Normal file
256
apps/cli/tests/github-repository-plugin.built.e2e.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
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)
|
||||
})
|
||||
@@ -22,7 +22,7 @@ if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
|
||||
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
|
||||
markers = [b"dsh-test: never-dispose ready", b"dsh-test: never-dispose started"]
|
||||
output = bytearray()
|
||||
marker_index = 0
|
||||
deadline = time.monotonic() + float(timeout_seconds)
|
||||
@@ -66,14 +66,14 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
try {
|
||||
const home = join(cwd, '.dsh')
|
||||
// Pre-initialize the headless profile with the never-dispose row in its
|
||||
// user patch layer (the same file `dsh --profile headless` hot-reloads).
|
||||
// user patch layer (the same file a long-lived profile boot hot-reloads).
|
||||
const profileDir = join(home, 'profiles', 'headless')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
await writeFile(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-headless',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] } },
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
|
||||
}, undefined, 2))
|
||||
await writeFile(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
@@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['--profile', 'headless', 'never complete'],
|
||||
configArgs: ['run', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
@@ -124,7 +124,8 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
|
||||
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
|
||||
const output = await runHeadlessPtySmoke()
|
||||
expect(output).toContain('dsh: observing at ')
|
||||
expect(output).not.toContain('dsh: observing at ')
|
||||
expect(output).toContain('dsh-test: never-dispose ready')
|
||||
expect(output).toContain('dsh-test: never-dispose started')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"path": "../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/ui/app-boot"
|
||||
"path": "../../packages/boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/bundle/base"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>DeepSeek Harness</title>
|
||||
</head>
|
||||
|
||||
16
apps/web/public/manifest.webmanifest
Normal file
16
apps/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "DeepSeek Harness",
|
||||
"short_name": "DSH",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "fullscreen",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
// model content as the question composer: the turn cannot complete without it).
|
||||
//
|
||||
// Geometry is the point of the scenario. The command is unbounded model text,
|
||||
// and before the cap a long one grew the card until the refuse/allow buttons
|
||||
// left the viewport — an approval the user could see and not answer.
|
||||
// and an uncapped card grows with it until the refuse/allow buttons leave the
|
||||
// viewport — an approval the user could see and not answer.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -36,8 +36,8 @@ const MODE = webSnapshotMode()
|
||||
|
||||
// Irreducible payload: the command has to be long enough to pass the card's
|
||||
// height cap, which is the only shape that reproduces an action row pushed off
|
||||
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
|
||||
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
|
||||
// screen. Unrelated tokens, not a repeated word — the model compresses a
|
||||
// repeated word into `printf 'alpha %.0s' {1..400}` when recording, and a
|
||||
// short command proves nothing here. The formula keeps the source small; the
|
||||
// model receives the expanded literal it has to put in the command.
|
||||
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
|
||||
@@ -89,7 +89,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
await input.fill('')
|
||||
|
||||
// Read-only: the mode whose denial the model escalates from. Switched
|
||||
// through the shipped access-mode chip, not a test-only seam.
|
||||
// through the shipped access-mode chip, not a test-only override.
|
||||
await page.locator('[aria-label^="Access mode"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await expect.poll(
|
||||
@@ -115,9 +115,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// The regression this scenario exists for: an uncapped card grew with
|
||||
// the command until the action row left the viewport. Measured at the
|
||||
// lane baseline and at a short viewport, on the live panel.
|
||||
// The uncapped-card hazard the header names, measured at the lane
|
||||
// baseline and at a short viewport, on the live panel.
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
for (const height of [1000, 700]) {
|
||||
await page.setViewportSize({ width: 900, height })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Shared scaffolding for the assembled-jsdom snapshots: the real built
|
||||
// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's
|
||||
// workspace `lib/client.js` artifacts booted through AppWebEntry's
|
||||
// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
|
||||
// transport. Every file that mounts this graph needs the same boot entry list,
|
||||
// the same bundle map, the same jsdom globals, and the same mount call, and
|
||||
@@ -14,18 +14,22 @@ import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
|
||||
const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
bundlePath: 'packages/client/ui-workspace/lib/client.js',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
@@ -34,12 +38,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
@@ -97,7 +101,7 @@ export function mountAssembledApp(): void {
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
loadBundle: async (url) => {
|
||||
@@ -115,7 +119,7 @@ export function mountAssembledApp(): void {
|
||||
* Match a CSS-module class by its logical name.
|
||||
* Module class names carry a per-build hash in one of two schemes —
|
||||
* ui-primitives emits `_<name>_<hash>` (name bounded by underscores),
|
||||
* ui-conversation emits `<hash>_<name>` (name at the end) — and a longer name
|
||||
* feature bundles emit `<hash>_<name>` (name at the end) — and a longer name
|
||||
* containing this one must not match (`line` must not hit `lineNumber`).
|
||||
* @param el - element whose class list is inspected.
|
||||
* @param name - logical (unhashed) module class name.
|
||||
|
||||
@@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Resolve the resident approval so the ordinary composer bar (which owns
|
||||
// ContextMeter) resumes without replacing the session shell. This minimal
|
||||
// boot graph intentionally does not mount the separate question UI plugin.
|
||||
@@ -95,7 +94,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
|
||||
.map(style => style.getAttribute('data-plugin'))
|
||||
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
|
||||
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-tool']) {
|
||||
expect(styleOwners).toContain(plugin)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
@@ -160,6 +161,14 @@ function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>):
|
||||
.join('')
|
||||
}
|
||||
|
||||
function messageKey(event: SessionEvent<'user/message'>): string {
|
||||
return conversationContextKey('input-message', String(event.data.id))
|
||||
}
|
||||
|
||||
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
|
||||
}
|
||||
|
||||
describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& userText(event).includes(spec.userMarker)
|
||||
)), { timeout: 15_000 }).toBe(true)
|
||||
const echoedUser = sessionEvents.slice(eventStart).find(
|
||||
(event): event is SessionEvent<'user/message'> => (
|
||||
event.type === 'user/message'
|
||||
@@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
),
|
||||
)
|
||||
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
|
||||
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await userRow.textContent()).toContain(spec.userMarker)
|
||||
@@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
|
||||
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
|
||||
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
|
||||
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
|
||||
|
||||
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
|
||||
// input only navigates to the semantic target; assertions pin content identity
|
||||
// and interaction routing rather than scroll geometry or mounted row counts.
|
||||
// Long-history Chat behavior contract that stays valid under a virtualized
|
||||
// renderer: wheel input only navigates to the semantic target; assertions pin
|
||||
// content identity and interaction routing rather than scroll geometry or
|
||||
// mounted row counts.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -10,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
@@ -115,6 +117,18 @@ function requiredEvent<T extends SessionEvent['type']>(
|
||||
return event
|
||||
}
|
||||
|
||||
function messageKey(event: SessionEvent<'user/message'>): string {
|
||||
return conversationContextKey('input-message', String(event.data.id))
|
||||
}
|
||||
|
||||
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
|
||||
}
|
||||
|
||||
function turnTailKey(turn: number): string {
|
||||
return conversationContextKey('turn-tail', String(turn))
|
||||
}
|
||||
|
||||
describe('web e2e: long Chat interaction contract', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -176,8 +190,10 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
const expectedUserText = textContent(branchUserEvent.data.content)
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
|
||||
const toolUserKey = messageKey(toolUserEvent)
|
||||
const toolAssistantKey = assistantKey(toolAssistantEvent)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
|
||||
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
|
||||
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
|
||||
|
||||
@@ -186,28 +202,27 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
expect(await call1.count()).toBe(1)
|
||||
expect(await call2.count()).toBe(1)
|
||||
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
|
||||
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
|
||||
expect(await call1.textContent()).toContain(toolMarker1)
|
||||
expect(await call2.textContent()).toContain(toolMarker2)
|
||||
|
||||
const expectedOrder = [
|
||||
`node:${String(toolUserEvent.seq)}`,
|
||||
`call:${TARGET_CALL_1}`,
|
||||
`call:${TARGET_CALL_2}`,
|
||||
`node:${String(toolAssistantEvent.seq)}`,
|
||||
toolUserKey,
|
||||
conversationContextKey('tool-call', TARGET_CALL_1),
|
||||
conversationContextKey('tool-call', TARGET_CALL_2),
|
||||
toolAssistantKey,
|
||||
]
|
||||
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
|
||||
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
|
||||
.filter((key): key is string => key !== undefined && keys.includes(key))
|
||||
), expectedOrder)
|
||||
expect(actualOrder).toEqual(expectedOrder)
|
||||
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
|
||||
const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
|
||||
))))
|
||||
expect(groupKeys[0]).not.toBeNull()
|
||||
expect(groupKeys[1]).toBe(groupKeys[0])
|
||||
expect(toolKinds).toEqual(['tool-call', 'tool-call'])
|
||||
|
||||
const summary1 = call1.locator('[data-sample="bash"]')
|
||||
const summary2 = call2.locator('[data-sample="bash"]')
|
||||
@@ -219,9 +234,12 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
|
||||
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
|
||||
const branchUserKey = messageKey(branchUserEvent)
|
||||
const branchAssistantKey = assistantKey(branchAssistantEvent)
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
|
||||
const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
|
||||
expect(await userRow.textContent()).toContain(branchUserMarker)
|
||||
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
@@ -230,8 +248,8 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
|
||||
.toBe(expectedUserText)
|
||||
|
||||
await assistantRow.hover()
|
||||
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await turnTailRow.hover()
|
||||
await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -40,6 +40,11 @@ const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
|
||||
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
|
||||
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
|
||||
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
|
||||
const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
|
||||
const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
|
||||
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
|
||||
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
|
||||
const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
|
||||
|
||||
const HISTORY_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'HISTORY',
|
||||
@@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({
|
||||
title: 'CHAT_SCROLL_RESTORE_B comparison session',
|
||||
turns: 32,
|
||||
})
|
||||
const INPUTS_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'INPUTS',
|
||||
title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
|
||||
})
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly distanceFromBottom: number
|
||||
@@ -273,6 +282,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch-style momentum fling over the transcript. Headless Chromium in the
|
||||
* test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture
|
||||
* and Input.dispatchTouchEvent both deliver DOM events without moving any
|
||||
* scroller, and compositor scrollbars ignore synthetic mouse input), so the
|
||||
* fling replays the signature a real pan leaves on the scrollport: per-frame
|
||||
* decaying displacements the component never authored, carrying no wheel
|
||||
* events. Wheel-sign semantics: positive deltaY reads downward.
|
||||
*/
|
||||
async function flingTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => {
|
||||
const direction = Math.sign(delta)
|
||||
let remaining = Math.abs(delta)
|
||||
// Fast launch decaying toward a floor speed, like a released finger. The
|
||||
// floor stays above the follow threshold so contended frames (streaming
|
||||
// writes racing the fling) still deviate far enough to read as input.
|
||||
let velocity = Math.max(120, remaining / 8)
|
||||
while (remaining > 0) {
|
||||
const step = Math.min(velocity, remaining)
|
||||
host.scrollTop += direction * step
|
||||
remaining -= step
|
||||
velocity = Math.max(48, velocity * 0.9)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
}
|
||||
}, deltaY)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelToHistoryStart(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if ((await scrollGeometry(page)).scrollTop <= 1) break
|
||||
@@ -683,4 +720,112 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
// Keyboard is the only non-wheel device this lane's Chromium can drive for
|
||||
// real (see flingTranscript for the probe results on touch and scrollbars),
|
||||
// so it stands in for the whole hardware input pipeline here.
|
||||
it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-keyboard',
|
||||
seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
await openSeed(
|
||||
world.page,
|
||||
INPUTS_FIXTURE,
|
||||
INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns),
|
||||
)
|
||||
await expectBottom(world.page)
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
|
||||
// Focus rides the last seeded tool row (a tabbable button whose keydown
|
||||
// handler passes scrolling keys through). End first normalizes the
|
||||
// focus-driven scrollIntoView back to the floor.
|
||||
const lastToolRow = world.page.locator(
|
||||
`[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
|
||||
)
|
||||
await lastToolRow.focus()
|
||||
await world.page.keyboard.press('End')
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
||||
for (let press = 0; press < 3; press += 1) {
|
||||
await world.page.keyboard.press('PageUp')
|
||||
await nextPaint(world.page)
|
||||
}
|
||||
await backToBottom.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 })
|
||||
.toBeGreaterThan(100)
|
||||
await world.page.keyboard.press('End')
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-fling-stream',
|
||||
replay: [
|
||||
replayEntry(toolStream()),
|
||||
replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
|
||||
],
|
||||
seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
|
||||
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
|
||||
await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
let released = false
|
||||
try {
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
await composer.fill(LIVE_FLING_PROMPT)
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
||||
await expectBottom(world.page)
|
||||
|
||||
// Fling away while the turn is mid-flight: the scroll burst alone must
|
||||
// release bottom ownership, exactly like a wheel scroll would, even
|
||||
// while streaming keeps re-asserting the floor between frames.
|
||||
await flingTranscript(world.page, -900)
|
||||
await backToBottom.waitFor({ timeout: 10_000 })
|
||||
const awayAnchor = await visibleFlowAnchor(world.page)
|
||||
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await writeFile(releasePath, 'release\n')
|
||||
released = true
|
||||
await expect.poll(
|
||||
() => world.events.some(event => event.type === 'tool/result'),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true)
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksBeforeRelease + 5)
|
||||
await expectSameFlowTop(world.page, awayAnchor)
|
||||
|
||||
// Fling back to the floor: re-pin must come from the reader's scroll
|
||||
// itself, and follow must then own the still-streaming tail. The
|
||||
// retry loop chases the floor that streaming keeps pushing down.
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break
|
||||
await flingTranscript(world.page, 1_600)
|
||||
}
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
||||
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksAtRepin + 5)
|
||||
await expectBottom(world.page)
|
||||
} finally {
|
||||
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
|
||||
}
|
||||
|
||||
await settled
|
||||
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expectBottom(world.page)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ const MODE = webSnapshotMode()
|
||||
|
||||
// The scenario's one drive prompt: elicits one program with a bash sub-call
|
||||
// and a failing read the program tolerates — the sub-row set the assertions
|
||||
// (and the PR gif) need. Never asserted against model prose.
|
||||
// need. Never asserted against model prose.
|
||||
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
|
||||
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// The parent run_code row wears the code variant with the model-authored
|
||||
// description as its summary (the PR1 presentCall contract).
|
||||
// description as its summary (the presentCall contract).
|
||||
const codeRow = page.locator('[data-variant="code"]').first()
|
||||
await codeRow.waitFor({ timeout: 10_000 })
|
||||
// Nested rows are visible WITHOUT any expand interaction, inside the
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
|
||||
// highlight, the chips and the ghost hint.
|
||||
//
|
||||
// Two layers can only stay together by moving together. They now do: both sit
|
||||
// Two layers can only stay together by moving together. They do: both sit
|
||||
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
|
||||
// tall as the whole draft — so one offset, applied by the browser, moves the
|
||||
// caret and the words in the same frame. Scrolling the textarea and assigning
|
||||
@@ -192,7 +192,7 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
* Absolute glyph coordinates are deliberately absent: they depend on font
|
||||
* metrics and would make the fixture fail on a machine that measures text
|
||||
* differently — a golden that needs re-recording per platform documents the
|
||||
* platform, not the change. What is recorded is the cap, the caret-to-glyph
|
||||
* platform, not the behavior. What is recorded is the cap, the caret-to-glyph
|
||||
* relation, and which lines are on screen, each a comparison that survives any
|
||||
* layout keeping the coupling.
|
||||
* @param top - metrics with the draft scrolled to its start.
|
||||
@@ -297,10 +297,10 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
|
||||
// A layer that breaks lines somewhere else puts the words under the wrong
|
||||
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
|
||||
// draft. The three now share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; before,
|
||||
// only the textarea scrolled, and WebKit reserved gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox did not.
|
||||
// draft. All three share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; with
|
||||
// only the textarea scrolling, WebKit reserves gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox do not.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
// The mirror decides the box height, so it belongs in the same equality —
|
||||
@@ -315,7 +315,7 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
// The reported symptom, isolated. A scroll offset changes and the caret's
|
||||
// distance to its own glyphs is re-read before the task ends — before any
|
||||
// `scroll` listener could have run. With the layers on one scrollport the
|
||||
// browser moved both, so the distance is unchanged; with the glyph layer
|
||||
// browser moves both, so the distance is unchanged; with the glyph layer
|
||||
// catching up in a listener it is off by the whole delta until a later
|
||||
// frame, which is a caret flying away from its text mid-gesture.
|
||||
const metrics = await measureComposer(page)
|
||||
@@ -348,9 +348,10 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
|
||||
// The other way the box moves, and the one that depends on the browser: the
|
||||
// textarea no longer scrolls, so revealing the caret after an edit is a
|
||||
// scroll-into-view that has to walk up to the scrollport. Scroll away from
|
||||
// the caret first, so the edit has somewhere to bring it back from.
|
||||
// textarea holds no scroll offset of its own, so revealing the caret after
|
||||
// an edit is a scroll-into-view that has to walk up to the scrollport.
|
||||
// Scroll away from the caret first, so the edit has somewhere to bring it
|
||||
// back from.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.press('End')
|
||||
await input.hover()
|
||||
@@ -368,9 +369,9 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
|
||||
// The composer suppresses the native paste — the machine owns the draft and
|
||||
// the undo log — and restores the caret programmatically, which reveals
|
||||
// nothing on its own: measured in chromium and WebKit, the view stayed
|
||||
// where it was while the caret sat at the end of the pasted block. The
|
||||
// restore now scrolls it into view, and this is the case that proves it.
|
||||
// nothing on its own: in chromium and WebKit the view stays put while the
|
||||
// caret sits at the end of the pasted block, so the restore scrolls it
|
||||
// into view; this case pins it.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill('one short line')
|
||||
await input.press('End')
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
// gets an absolutely positioned seat instead, laid out against the padding box,
|
||||
// which the scrollbar never reduces.
|
||||
//
|
||||
// So the two tabs disagreed by exactly the bar's width for as long as the
|
||||
// transcript overflowed: the card jumped sideways on every tab switch, and
|
||||
// inside Chat alone at the moment a growing transcript started to scroll. The
|
||||
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
|
||||
// and states the overlay branch as a scroll container on the same axes, so both
|
||||
// edges are the same edge.
|
||||
// Without a shared reservation the two tabs disagree by exactly the bar's
|
||||
// width for as long as the transcript overflows: the card jumps sideways on
|
||||
// every tab switch, and inside Chat alone at the moment a growing transcript
|
||||
// starts to scroll. The column reserves the gutter unconditionally
|
||||
// (`scrollbar-gutter: stable`) and states the overlay branch as a scroll
|
||||
// container on the same axes, so both edges are the same edge.
|
||||
//
|
||||
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
|
||||
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
|
||||
@@ -26,14 +26,14 @@
|
||||
//
|
||||
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
|
||||
// which is load-bearing rather than incidental. Under that argument a scroll
|
||||
// container's bar consumes no layout width at all, so the two tabs agree before
|
||||
// this change as much as after it and every comparison below holds vacuously —
|
||||
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
|
||||
// and 0 with the argument dropped. Dropping it is also the faithful
|
||||
// container's bar consumes no layout width at all, so the two tabs agree with
|
||||
// and without the reservation and every comparison below holds vacuously —
|
||||
// measured: the unreserved cascade leaves both tabs' bands at 0 there, against
|
||||
// 8 and 0 with the argument dropped. Dropping it is also the faithful
|
||||
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
|
||||
// and a bar that occupies layout space is what the product actually draws.
|
||||
//
|
||||
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
|
||||
// The scenario runs that unreserved cascade in the page — `scrollbar-gutter: auto`
|
||||
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
|
||||
// same two tabs through it, which is what keeps the equal rectangles above from
|
||||
// being explained by a tab switch that never reached the layout. It is the
|
||||
@@ -64,7 +64,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry',
|
||||
* that has to be re-recorded per platform. What is recorded is the distance
|
||||
* between the two tabs' rectangles, which is zero when the reservation holds and
|
||||
* the bar's width when it does not — including under the control, so the golden
|
||||
* carries the difference the fix removes rather than only its absence.
|
||||
* carries the shift the unreserved cascade produces rather than only its absence.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -114,10 +114,10 @@ async function setMeasuredViewport(
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-fix cascade, injected into the page: the reservation dropped and the
|
||||
* overlay branch back to a hidden box. `!important` beats the module rules
|
||||
* without a rebuild, and the id lets the control be lifted again in the same
|
||||
* session.
|
||||
* The unreserved cascade, injected into the page: the reservation dropped and
|
||||
* the overlay branch forced to a hidden box. `!important` beats the module
|
||||
* rules without a rebuild, and the id lets the control be lifted again in the
|
||||
* same session.
|
||||
*/
|
||||
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
|
||||
const CONTROL_CSS = `
|
||||
@@ -221,9 +221,9 @@ async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-fix cascade in the page for one measurement, then lift it.
|
||||
* Run the unreserved cascade in the page for one measurement, then lift it.
|
||||
* @param page - the page under test.
|
||||
* @returns the comparison as the column laid out before this change.
|
||||
* @returns the comparison as the column lays out without the reservation.
|
||||
*/
|
||||
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
|
||||
await page.evaluate(({ id, css }) => {
|
||||
@@ -328,7 +328,7 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
|
||||
const comparison = await compareTabs(page)
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
// The reservation reaches both states, which is the whole change: the same
|
||||
// The reservation reaches both states, which is the whole point: the same
|
||||
// band, on a box that scrolls and on one that only holds a view.
|
||||
expect(comparison.chat.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.gutter).toBe('stable')
|
||||
@@ -348,8 +348,8 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const comparison = await compareTabs(page)
|
||||
// The reported symptom as a number. At this viewport the card sits at its
|
||||
// width cap, so the pre-fix shift showed up as a centring difference — half
|
||||
// the band on each edge — rather than as a width change.
|
||||
// width cap, so the unreserved cascade's shift shows up as a centring
|
||||
// difference — half the band on each edge — rather than as a width change.
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
@@ -363,7 +363,7 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
||||
const comparison = await compareTabs(page)
|
||||
// The other geometry, and a different failure: below the cap the card takes
|
||||
// the column's width, so an unreserved gutter changed its WIDTH by the whole
|
||||
// the column's width, so an unreserved gutter changes its WIDTH by the whole
|
||||
// band instead of shifting it by half. Asserted against the capped
|
||||
// measurement rather than against the cap's pixel value, which belongs to
|
||||
// the stylesheet.
|
||||
@@ -379,17 +379,17 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
// The control: without it, equal rectangles could also mean the tab switch
|
||||
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
|
||||
// its bar and the Trajectory branch goes back to a hidden box with none, and
|
||||
// the card moves by half the band on each edge.
|
||||
// never reached the layout. Under the unreserved cascade the Chat scroller
|
||||
// keeps its bar and the Trajectory branch becomes a hidden box with none,
|
||||
// and the card moves by half the band on each edge.
|
||||
const comparison = await compareTabsWithoutReservation(page)
|
||||
expect(comparison.chat.gutter).toBe('auto')
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
expect(comparison.trajectory.band).toBe(0)
|
||||
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
|
||||
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
|
||||
// Restoring the sheet restores the fix, so the control cannot leak into the
|
||||
// remaining measurements.
|
||||
// Restoring the sheet restores the reservation, so the control cannot leak
|
||||
// into the remaining measurements.
|
||||
const restored = await compareTabs(page)
|
||||
expect(restored.leftShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Web e2e scenario: the conversation column scrolls on one axis only, as the
|
||||
// browser actually lays it out. The reported symptom was a horizontal
|
||||
// scrollbar under the whole center column once the window (or the sidebar
|
||||
// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the
|
||||
// column and becoming user-scrollable.
|
||||
// browser actually lays it out. The hazard: a horizontal scrollbar appears
|
||||
// under the whole center column once the window (or the sidebar drag) narrows
|
||||
// it — the hero's decorative backdrop ellipse bleeds past the column and
|
||||
// becomes user-scrollable.
|
||||
//
|
||||
// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
|
||||
// hero box (ConversationRoot.module.css) so the blur scales with the input
|
||||
// card. What changed is the scroll container: `[data-conversation-scroll]`
|
||||
// scrolls vertically, and a box that scrolls in one axis computes the other
|
||||
// axis's initial `visible` to `auto`, so the bleed came back as a bar. The
|
||||
// fix states `overflow-x: hidden` there.
|
||||
// card. The scroll container is where the bar comes from:
|
||||
// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller
|
||||
// computes the other axis's initial `visible` to `auto`, so the bleed becomes
|
||||
// a bar; `overflow-x: hidden` on the scroller prevents it.
|
||||
//
|
||||
// Only a real engine reports that pair — the bleed and the resulting scroll
|
||||
// range — so the scenario sweeps viewport widths that bracket the glow's
|
||||
@@ -36,7 +36,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-over
|
||||
* Committed golden of the one-axis relation at every stop. It records
|
||||
* relations and booleans, never absolute coordinates: the column width follows
|
||||
* the viewport and the sidebar, and a golden carrying pixels would document the
|
||||
* platform instead of the change.
|
||||
* platform instead of the behavior.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -60,17 +60,20 @@ interface ColumnMetrics {
|
||||
columnWidth: number
|
||||
/** Resolved `overflow-x` on the conversation scroll container. */
|
||||
overflowX: string
|
||||
/** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */
|
||||
/**
|
||||
* True when the glow's box reaches past the column's content edge — the
|
||||
* condition the `overflow-x: hidden` declaration has to survive.
|
||||
*/
|
||||
glowBleeds: boolean
|
||||
/**
|
||||
* `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
|
||||
* `auto` both report the same value, because `hidden` clips the bleed rather
|
||||
* than reflowing it away. Recorded because it is the vacuity guard in
|
||||
* numbers — it must stay positive at the narrow stops, or the scenario has
|
||||
* stopped reproducing the situation the fix is for.
|
||||
* stopped reproducing the situation `overflow-x: hidden` exists for.
|
||||
*/
|
||||
bleedRange: number
|
||||
/** True when the column still scrolls vertically — the axis the fix must not take away. */
|
||||
/** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */
|
||||
scrollsVertically: boolean
|
||||
}
|
||||
|
||||
@@ -108,9 +111,10 @@ function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
|
||||
* This is the one signal that separates the two states, and it is why the
|
||||
* scenario needs a real engine: `overflow-x: hidden` leaves the box
|
||||
* programmatically scrollable and leaves `scrollWidth` untouched, so every
|
||||
* property reading agrees across the fix. Only refusing an actual input event
|
||||
* differs — measured at the 1200px stop, the shipped column stays at 0 while
|
||||
* the same page with `overflow-x: auto` forced on lands at its scroll boundary.
|
||||
* property reading agrees across the two overflow modes. Only refusing an
|
||||
* actual input event differs — measured at the 1200px stop, the shipped
|
||||
* column stays at 0 while the same page with `overflow-x: auto` forced on
|
||||
* lands at its scroll boundary.
|
||||
* @param page - the page under test.
|
||||
* @returns `scrollLeft` after one horizontal wheel over the column.
|
||||
*/
|
||||
@@ -175,10 +179,10 @@ type ColumnStop = ColumnMetrics & {
|
||||
* Render the golden body: one line per stop, relations only.
|
||||
*
|
||||
* Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
|
||||
* which the fix pins to 0 by construction. The bleed is recorded as a boolean
|
||||
* rather than its width, so the golden survives any platform whose column
|
||||
* lands a pixel off — a fixture that has to be re-recorded per platform
|
||||
* documents the platform, not the change.
|
||||
* which the shipped overflow mode pins to 0 by construction. The bleed is
|
||||
* recorded as a boolean rather than its width, so the golden survives any
|
||||
* platform whose column lands a pixel off — a fixture that has to be
|
||||
* re-recorded per platform documents the platform, not the behavior.
|
||||
* @param stops - the measured stops, in sweep order.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
@@ -272,18 +276,19 @@ describe('web e2e: the conversation column scrolls on one axis', () => {
|
||||
// The reported symptom, stated directly: a horizontal wheel over the
|
||||
// column moves nothing, at every stop.
|
||||
expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
|
||||
// The axis the column is a scroller for must survive the fix.
|
||||
// The axis the column is a scroller for must survive `overflow-x: hidden`.
|
||||
expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('reports the pre-fix state when the axis is opened back up', async () => {
|
||||
it('scrolls horizontally again once the axis is opened back up (control)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
|
||||
// The mutation control, run in the page rather than against a second
|
||||
// build: it restores exactly what the fix changed — the initial `visible`
|
||||
// that a one-axis scroller computes to `auto` — and shows the same gesture,
|
||||
// at the same timing, carrying the column to its positive scroll boundary.
|
||||
// build: it lifts exactly the `overflow-x: hidden` declaration, so the
|
||||
// initial `visible` that a one-axis scroller computes to `auto` takes
|
||||
// over, and shows the same gesture, at the same timing, carrying the
|
||||
// column to its positive scroll boundary.
|
||||
// Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
|
||||
// Injected with an id rather than through `addStyleTag`, so the teardown
|
||||
// below can take the sheet out again by selector: it must not outlive this
|
||||
|
||||
95
apps/web/tests/declared-reasoning.e2e.ts
Normal file
95
apps/web/tests/declared-reasoning.e2e.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
|
||||
// composer's effort pane — the levels a settings profile declares are exactly
|
||||
// what the picker offers, and picking one records it with the Agent default.
|
||||
// Zero model calls: declaring, describing, and switching are settings/llm
|
||||
// traffic only, so there is no fixture and a stray stream would fail loud.
|
||||
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 { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Starts the shipped default on this scenario's declared reasoning model. */
|
||||
const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// The whole reasoning offer is the profile: key = selectable level, value
|
||||
// = the wire spelling dispatch would send (`max: ultra` renames; the
|
||||
// valueless `off` means "supported, send nothing"). The route sets no
|
||||
// deployment default, so the pane leads with the provider-default entry.
|
||||
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
name: 'Acme Think',
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('offers exactly the declared levels and records the picked one', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /推理等级/ }).click()
|
||||
|
||||
// Declared levels, nothing else: the provider-default entry (the route
|
||||
// configures no `reasoning`), then Off/High/Max — minimal, low, medium,
|
||||
// and xhigh were not declared and must not be offered.
|
||||
const levels = page.getByRole('menuitemradio')
|
||||
await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 })
|
||||
.toEqual(['Default', 'Off', 'High', 'Max'])
|
||||
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// Picking a level is the same gesture that saves the default selection, so
|
||||
// the effort lands in the Agent default Settings section beside provider/model.
|
||||
await page.getByRole('menuitemradio', { name: 'High' }).click()
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('reasoningEffort: high')
|
||||
await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('选择模型,当前 Acme Think,推理等级 High')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
8
apps/web/tests/declared-reasoning.overlay.yml
Normal file
8
apps/web/tests/declared-reasoning.overlay.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
# The fixture-less web scaffold registers no adapter, so the shipped
|
||||
# deepseek-official default would be a route nothing serves. This scenario
|
||||
# starts the default on its own declared reasoning model so the effort pane
|
||||
# describes that model from the first open.
|
||||
- id: agent-default-model
|
||||
config:
|
||||
provider: acme-gateway
|
||||
model: acme-think
|
||||
167
apps/web/tests/default-model.e2e.ts
Normal file
167
apps/web/tests/default-model.e2e.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// Web e2e scenario: switching models in the composer is how this deployment's
|
||||
// default is chosen. The gesture writes the shared `agent-default-model` settings section, a
|
||||
// session created afterwards starts from it, and a session that already logged
|
||||
// a route keeps deriving from its own log — the tier order the gateway
|
||||
// resolves on every read.
|
||||
// Zero model calls: the switch is settings/llm-domain traffic only, so there
|
||||
// is no fixture and a stray stream would fail loud because the adapter registry is empty. Both
|
||||
// routes are declared host-side (not through the UI, which has its own
|
||||
// scenario) through the pi-ai adapter the shipped tree already mounts: a
|
||||
// fixture-less scaffold registers no adapter at all, so the routes the
|
||||
// picker offers — and the one the composer must start on — have to come from
|
||||
// somewhere, and settings profiles are the product's own way to add them.
|
||||
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 { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Points the shipped shared Agent default at this scenario's own route. */
|
||||
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
||||
|
||||
/** The route this scenario starts on, patched over the shipped default. */
|
||||
const START_ROUTE = 'origin-gateway'
|
||||
const START_MODEL = 'origin-large'
|
||||
/** The route the switch lands on, which then becomes the saved default. */
|
||||
const ROUTE = 'acme-gateway'
|
||||
const MODEL = 'acme-large'
|
||||
|
||||
describe('web e2e: the composer model switch is the default for later sessions', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
/** Create one session and its agent through the same wire face the browser uses. */
|
||||
const createSession = async (sessionId: string): Promise<string> => {
|
||||
const response = await scaffold.ctx.apiProxy.sessions.create({
|
||||
rpcId: `default-model-create-${sessionId}` as never,
|
||||
payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
|
||||
return response.result.value.sessionId
|
||||
}
|
||||
|
||||
/** The route the gateway reports for one session, through the real wire face. */
|
||||
const currentOf = async (sessionId: string): Promise<unknown> => {
|
||||
const response = await scaffold.ctx.apiProxy.sessions.models({
|
||||
rpcId: `default-model-${sessionId}` as never,
|
||||
payload: { sessionId: SessionId(sessionId) },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
|
||||
return response.result.value.current
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// Two routes so the picker has somewhere to start and somewhere to go.
|
||||
// Declared through the settings seam rather than the Models page: this
|
||||
// scenario is about the composer, and the declaring flow is covered by
|
||||
// models-settings.e2e.
|
||||
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
[START_ROUTE]: {
|
||||
displayName: 'Origin Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.origin.example/v1',
|
||||
models: [{ id: START_MODEL, name: 'Origin Large' }],
|
||||
},
|
||||
[ROUTE]: {
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: MODEL, name: 'Acme Large' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The composer's seats only exist once a workspace is connected: without
|
||||
// one the input is the locked placeholder and no session scope is open.
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('writes the switched model as the default and leaves a logged session alone', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
|
||||
// A session that has already run a turn, spelled as the fact a turn
|
||||
// leaves behind: its own logged route.
|
||||
const loggedId = await createSession('default-model-logged')
|
||||
scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
|
||||
header: { config: { provider: START_ROUTE, model: START_MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
||||
|
||||
// The switch is what sets the default: the shared Agent-route settings section
|
||||
// now names it, beside the provider profiles the Models page writes.
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('agent-default-model:')
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain(`provider: ${ROUTE}`)
|
||||
expect(document).toContain(`model: ${MODEL}`)
|
||||
|
||||
// A session created after the switch starts from it...
|
||||
expect(await currentOf(await createSession('default-model-after')))
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('goes inert when the route the default names stops being served', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
|
||||
const box = page.locator('textarea[data-input-phase], textarea').first()
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
// What removing the provider on the Models page leaves behind: the saved
|
||||
// default still names the route, and nothing serves it any more.
|
||||
// `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
|
||||
// stored profile in place.
|
||||
await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
|
||||
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
|
||||
expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
|
||||
|
||||
// The block is an affordance; the refusal is the Host's. A client that
|
||||
// never disabled anything still cannot start a turn on a dead route.
|
||||
const refused = await scaffold.ctx.apiProxy.sessions.prompt({
|
||||
rpcId: 'default-model-refused' as never,
|
||||
payload: {
|
||||
sessionId: SessionId(await createSession('default-model-refusal')),
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'hi' }],
|
||||
},
|
||||
})
|
||||
expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
|
||||
|
||||
// The way out stays open. Locking the model seat with everything else
|
||||
// would leave the composer asking for the one thing it prevents.
|
||||
const seat = page.getByRole('button', { name: /^选择模型/ })
|
||||
expect(await seat.isEnabled()).toBe(true)
|
||||
await seat.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio').first().click()
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
8
apps/web/tests/default-model.overlay.yml
Normal file
8
apps/web/tests/default-model.overlay.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
# The fixture-less web scaffold registers no adapter, so the shipped
|
||||
# deepseek-official default would be a route nothing serves — which the
|
||||
# composer refuses to type into. This scenario declares its own
|
||||
# pi-ai routes and starts the default on one of them.
|
||||
- id: agent-default-model
|
||||
config:
|
||||
provider: origin-gateway
|
||||
model: origin-large
|
||||
@@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
|
||||
|
||||
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
|
||||
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
|
||||
await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
|
||||
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
|
||||
const originalSource = await readFile(sourcePath)
|
||||
const originalBundle = await readFile(bundlePath)
|
||||
const oldText = "Let's start building"
|
||||
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
|
||||
const oldText = 'Into the Unknown'
|
||||
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
|
||||
const newText = `HMR UPDATED ${'x'.repeat(80)}`
|
||||
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
|
||||
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
|
||||
// gateway), reload replays everything from the log (zero further model
|
||||
// calls), and the theme scenario proves the shipped dark palette actually
|
||||
// cascades: attribute -> alias token flip -> painted surface change. Per the
|
||||
// lane's scope ruling there is no theme/layout golden (aria is color-blind);
|
||||
// the hero's waiting state gets the one golden here.
|
||||
// cascades: attribute -> alias token flip -> painted surface change. No
|
||||
// theme/layout golden: aria snapshots are color-blind (lane scope: the
|
||||
// browser-e2e-lane Agent Note); the hero's waiting state gets the one golden
|
||||
// here.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -112,8 +113,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
await planButton.waitFor({ timeout: 10_000 })
|
||||
// The golden encodes an empty composer, and the button arriving does not
|
||||
// mean the submitted text is gone yet: under load the capture caught a
|
||||
// textbox still holding `/plan`.
|
||||
// mean the submitted text is gone yet: under load the capture can catch
|
||||
// a textbox still holding `/plan`.
|
||||
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
|
||||
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
|
||||
@@ -159,7 +160,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
}
|
||||
// The blank frame renders the hero, not the resident composer: the
|
||||
// headline plus the guidance placeholder are the empty state's anchors.
|
||||
await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
if (MODE !== 'record') {
|
||||
@@ -232,7 +233,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
|
||||
it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
|
||||
// This scenario pins the ThemeService's DOM contract seam directly (the
|
||||
// This scenario pins the ThemeService's DOM contract directly (the
|
||||
// body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
|
||||
// user gesture above it (Settings -> Appearance cubes) is owned by
|
||||
// settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
|
||||
// and transient-retry recovery, all through the real composition and wire.
|
||||
// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
|
||||
// The model adapter is dsh-llm-replay with override sidecars: `hang` (+ a
|
||||
// readyFile marker) makes mid-stream cancel deterministic by construction,
|
||||
// `throw` entries express provider failures by stable code, and `{ patches }`
|
||||
// augmentation injects a transient throw before the recorded success so
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// provider status. The customized-settings fold writes the curated
|
||||
// reasoning field as a merge patch. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud on the open seam. The provider under test is
|
||||
// stray stream would fail loud because the adapter registry is empty. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference. The deletion dialog distinguishes a
|
||||
// reference-free profile from a page-managed key before the credential and
|
||||
@@ -27,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
|
||||
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -78,6 +79,26 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('refuses a key no HTTP header can carry before anything is written', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
const key = dialog.getByLabel('API 密钥')
|
||||
const save = dialog.getByRole('button', { name: '保存', exact: true })
|
||||
|
||||
// A key no HTTP header can carry would save cleanly and fail the first
|
||||
// turn with a ByteString TypeError; the form names the offending field
|
||||
// instead.
|
||||
await key.fill('sk-\u{1F600}minimax')
|
||||
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
|
||||
|
||||
// Clearing it restores submit: an empty field means "keep what is stored",
|
||||
// never a refusal, or editing any other setting would demand the key.
|
||||
await key.fill('')
|
||||
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it('saves a blank key as a reference-free provider-native profile', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -114,7 +135,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The profile lands in settings.yaml with only the derived reference, the
|
||||
// key value lands in the harness home's .env, the dormant route
|
||||
// key value lands in the harness home's .credentials.yaml, the dormant route
|
||||
// registers, and the topology frame invalidates the page into the row.
|
||||
await expect.poll(
|
||||
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
|
||||
@@ -126,11 +147,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(document).toContain('minimax-cn:')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
expect(document).not.toContain('sk-e2e-minimax')
|
||||
const credentialFile = join(scaffold.harnessHome, '.env')
|
||||
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
|
||||
await expect.poll(
|
||||
async () => readFile(credentialFile, 'utf8').catch(() => ''),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
|
||||
expect(await page.content()).not.toContain('sk-e2e-minimax')
|
||||
}, 60_000)
|
||||
|
||||
@@ -139,22 +160,55 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
|
||||
await dialog.getByText('自定义设置').click()
|
||||
const effort = dialog.getByLabel('推理强度')
|
||||
await effort.waitFor({ timeout: 10_000 })
|
||||
await effort.selectOption('high')
|
||||
const url = dialog.getByLabel('API 地址')
|
||||
await url.waitFor({ timeout: 10_000 })
|
||||
await url.fill('https://gateway.minimax.example/v1')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The editor closes back to the row; the fold's write merged into the
|
||||
// stored profile beside the reference.
|
||||
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
|
||||
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('reasoning: high')
|
||||
expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('declares a route the adapter does not ship', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
|
||||
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await declare.click()
|
||||
await dialog.getByLabel('Provider ID').fill('acme-gateway')
|
||||
await dialog.getByLabel('显示名称').fill('Acme Gateway')
|
||||
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
|
||||
// No reasoning effort on a provider card at all: effort is a per-model
|
||||
// capability, the models under one provider disagree about it, and a
|
||||
// switch in the composer already records provider+model+effort together.
|
||||
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
|
||||
await dialog.getByRole('button', { name: '添加模型' }).click()
|
||||
await dialog.getByLabel('模型 ID 1').fill('acme-large')
|
||||
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
|
||||
|
||||
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('acme-gateway:')
|
||||
|
||||
// The tag follows the adapter's installed catalog: this route is in no
|
||||
// catalog, while minimax-cn is — even though both now have profiles.
|
||||
const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first()
|
||||
await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms an identified provider deletion before removing its profile and key', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -177,7 +231,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).not.toContain('minimax-cn:')
|
||||
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
|
||||
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
|
||||
.not.toContain('MINIMAX_CN_API_KEY')
|
||||
await expect.poll(
|
||||
async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(),
|
||||
@@ -189,7 +243,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
|
||||
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
|
||||
'empty.expected.md', 'native-delete.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
// its single row and overflow sideways instead of folding. Soft-wrapping
|
||||
// here is what shredded the column alignment this card exists to hold.
|
||||
// here shreds the column alignment this card exists to hold.
|
||||
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
|
||||
const pane = node as HTMLElement
|
||||
const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
|
||||
|
||||
@@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the
|
||||
# Loader overlay for the real-host smoke (`dsh web --patch`): pin the
|
||||
# in-browser directory picker. The shipped row is `-auto`, which resolves to
|
||||
# the native OS chooser on a loopback bind with a local display — an
|
||||
# interaction a Playwright page cannot drive, so the resolved backend would
|
||||
|
||||
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal file
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds
|
||||
// a built write turn (zero model calls) whose closing message names the written
|
||||
// file three ways: by unique basename (links), ambiguously (stays inert), and
|
||||
// as a file the turn never touched (stays inert). Package tests cover the
|
||||
// resolver in isolation; only the assembled application shows a real write's
|
||||
// locations reaching the prose as an opener. The click itself is not driven
|
||||
// here: it hands the path to the Host's opener, which would launch a real
|
||||
// application on the machine running the suite (the produced-files restraint).
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'produced-file-mentions-web-e2e'
|
||||
const DONE = 'FILE_MENTION_DONE'
|
||||
|
||||
/** One-part text content for a built message. */
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
return [{ type: 'text', text: value }]
|
||||
}
|
||||
|
||||
/** The files the built turn writes; `notes.md` is named in prose but never written. */
|
||||
const WRITES = ['site/report.html', 'a/style.css', 'b/style.css']
|
||||
|
||||
/** Build a settled write turn whose closing prose mentions files in inline code. */
|
||||
function mentionFixture(): string {
|
||||
const session = Session.create(SessionId('produced-file-mentions-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Produced file mentions',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const calls = WRITES.map((path, index) => ({
|
||||
path,
|
||||
callId: CallId(`file-mention-${String(index)}`),
|
||||
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
|
||||
}))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: calls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
id: call.callId,
|
||||
name: 'write',
|
||||
arguments: call.args,
|
||||
})),
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
for (const call of calls) {
|
||||
const source = session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: call.callId,
|
||||
name: 'write',
|
||||
arguments: call.args,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: call.callId,
|
||||
content: text(`Created ${call.path}`),
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createAssistantMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
|
||||
'',
|
||||
DONE,
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: inline-code mentions of produced files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, mentionFixture(), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
// Exactly one prose mention links: `report.html` resolves to the written
|
||||
// path; the shared `style.css` basename and unwritten `notes.md` stay code.
|
||||
const mentions = page.locator('[class*="markdown"] code button')
|
||||
await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await mentions.first().innerText()).toBe('report.html')
|
||||
expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
|
||||
expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
|
||||
// The turn still ends with its produced-files row (all three writes).
|
||||
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
76
apps/web/tests/produced-files.e2e.ts
Normal file
76
apps/web/tests/produced-files.e2e.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds
|
||||
// a recorded write turn (zero model calls). Package tests cover the derivation
|
||||
// in isolation, but only the assembled application shows that a turn's writes
|
||||
// reach the transcript as an openable row (docs/testing.md snapshot rule). The
|
||||
// click itself is not driven here: it hands the path to the Host's opener,
|
||||
// which would launch a real application on the machine running the suite.
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a
|
||||
// file, not a new recording (the message-actions borrowing pattern).
|
||||
const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'produced-files-web-e2e'
|
||||
|
||||
/** The file the borrowed recording's write tool produces. */
|
||||
const PRODUCED = 'policy-neutral.txt'
|
||||
|
||||
describe('web e2e: a finished turn ends with the files it produced', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The seeded Session's cwd is the scaffold workspace; the recording's own
|
||||
// nested directory is created too, so its paths stay resolvable.
|
||||
await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true })
|
||||
await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n')
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED)
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
|
||||
// The row the turn ends with — derived from the write call's locations,
|
||||
// not from whatever the closing message happened to say.
|
||||
const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first()
|
||||
await chip.waitFor({ timeout: 15_000 })
|
||||
expect(await chip.innerText()).toBe(PRODUCED)
|
||||
// The full path stays reachable for a reader who wants to copy it.
|
||||
expect(await chip.getAttribute('title')).toContain(PRODUCED)
|
||||
// A turn's produced files are labelled, not left as bare chips.
|
||||
expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
27
apps/web/tests/pwa-manifest.e2e.ts
Normal file
27
apps/web/tests/pwa-manifest.e2e.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
|
||||
|
||||
it('ships install metadata with the built web application', async () => {
|
||||
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
|
||||
expect(index).toContain('<link rel="manifest" href="/manifest.webmanifest" />')
|
||||
|
||||
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
|
||||
expect(manifest).toEqual({
|
||||
id: '/',
|
||||
name: 'DeepSeek Harness',
|
||||
short_name: 'DSH',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'fullscreen',
|
||||
icons: [{
|
||||
src: '/favicon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
}],
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
|
||||
// api-proxy recomputes presentation views from logged args/result content)
|
||||
// must render as a bash-shaped terminal card with the parsed exit-status
|
||||
// pill — not the generic console-fenced card the pwsh presenter used to
|
||||
// emit. The seed is authored, not recorded: its header line carries no `cwd`
|
||||
// pill — not a generic console-fenced card. The seed is authored, not
|
||||
// recorded: its header line carries no `cwd`
|
||||
// field (seedSession writes the session cwd itself, and a Windows temp path
|
||||
// substituted into the header would not round-trip through its JSON parse),
|
||||
// and no event references the workspace, so the lane replays on any host
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
|
||||
// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo
|
||||
// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless)
|
||||
// in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless)
|
||||
// or the live adapter (record). Drive steps run in every mode and wait only
|
||||
// on generic completion (whenTurnSettled — never model-content selectors, so
|
||||
// record cannot hang on a live model answering differently); assertion steps
|
||||
|
||||
@@ -32,7 +32,7 @@ import { expect } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import {
|
||||
addHarnessSourceSection,
|
||||
assertEntriesLoaded,
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -93,6 +97,46 @@ const REPLAY_PROVIDERS = [{
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
|
||||
}]
|
||||
|
||||
/**
|
||||
* The routes a shipped composition always has, with no ability to stream.
|
||||
* A fixture-less keyless scenario issues no model calls, but its tree must
|
||||
* still answer `listProviders()` — surfaces legitimately gate on whether any
|
||||
* adapter serves a session's route, and an empty registry is a test artifact,
|
||||
* not a product state.
|
||||
*/
|
||||
class RouteOnlyAdapter extends LlmAdapter {
|
||||
constructor(private readonly providers: typeof REPLAY_PROVIDERS) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? [])
|
||||
.map(model => ({ provider, id: model.id, name: model.name })))
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const listed = this.providers.find(entry => entry.id === provider)?.models
|
||||
.find(entry => entry.id === model)
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: listed?.name ?? model,
|
||||
...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow },
|
||||
})
|
||||
}
|
||||
|
||||
override async *stream(): AsyncIterable<StreamChunk> {
|
||||
throw new Error(
|
||||
'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture'
|
||||
+ ' — pass replayFixture, or keep the scenario free of model calls',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
|
||||
if (contextWindow === undefined) return REPLAY_PROVIDERS
|
||||
return REPLAY_PROVIDERS.map(provider => ({
|
||||
@@ -107,7 +151,7 @@ export interface WebScaffold {
|
||||
mode: WebSnapshotMode
|
||||
/** Browser-facing origin for the bound test server. */
|
||||
baseUrl: string
|
||||
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
|
||||
/** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */
|
||||
ctx: Context
|
||||
/** Temp project directory sessions run in (bash/fs tool cwd). */
|
||||
workspaceCwd: string
|
||||
@@ -390,6 +434,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
|
||||
// No fixture and no shipped adapter would leave the tree with ZERO
|
||||
// provider routes — a state no product composition has, and one the
|
||||
// composer refuses to type into. Register the same routes
|
||||
// a fixture would, with streaming that still fails loud: the scenario
|
||||
// issues no model calls, and one that slipped in must not pass quietly.
|
||||
ctx.effect(() => ctx.llm.registerAdapter(
|
||||
replayProviders(options.replayContextWindow).map(provider => provider.id),
|
||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
|
||||
), 'web e2e scaffold: route-only adapter')
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
@@ -474,11 +528,14 @@ function rawSessionLog(session: Session): string {
|
||||
export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise<void> {
|
||||
const agent = scaffold.ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
|
||||
const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
|
||||
const fresh = scrubRequestHeaders(rawSessionLog(agent.session))
|
||||
.split(sessionId).join('{{sessionId}}')
|
||||
.split(scaffold.workspaceCwd).join('{{cwd}}')
|
||||
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
|
||||
await writeFile(fixturePath, tokenized)
|
||||
const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : ''
|
||||
const stable = stabilizeFixtureMessageIds([fresh], [existing])[0]
|
||||
if (stable === undefined) throw new Error('record harvest: no stabilized fixture')
|
||||
await writeFile(fixturePath, stable)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,8 +620,9 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and
|
||||
* decode-throughput volatility collapse to stable tokens.
|
||||
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration,
|
||||
* decode-throughput, and path-sensitive compaction estimates collapse to
|
||||
* stable tokens.
|
||||
*
|
||||
* Throughput needs a token for the same reason durations do, and no fixture
|
||||
* can supply one: the figure divides a replayed step's output tokens by the
|
||||
@@ -591,6 +649,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
duration => duration.startsWith('约') ? duration : '{{duration}}',
|
||||
)
|
||||
.replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
|
||||
// Seeded compaction prices realized file paths, whose length differs
|
||||
// between local worktrees and CI scratch directories.
|
||||
.replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2')
|
||||
// Message IconActions clocks widen by calendar day/year; collapse every
|
||||
// shape so goldens stay stable across midnight and year boundaries.
|
||||
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
|
||||
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
|
||||
// Assembled search-card snapshot: boots the real built workspace client bundles
|
||||
// through AppWebEntry's ModuleLoader path against the keyless
|
||||
// FixtureApiClient transport (no API key, no model round), opens the fixture
|
||||
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
|
||||
// session, and pins the search card the `grep` turn (fixture turn 67) renders in
|
||||
// the assembled application. The built-boot smoke proves the graph boots but
|
||||
// carries no behavior assertions by contract; this is the assembled-output check
|
||||
// that a broken SearchRow registration or a dropped card would fail — the
|
||||
@@ -51,7 +51,7 @@ describe('assembled search card', () => {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
// Wait for chat content to reach the fixture's later turns (the bash sample
|
||||
// is turn 65, the grep card turn 66).
|
||||
// is turn 66, the grep card turn 67).
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// history RPC, history-page tool views, and the client's log-ordered transcript
|
||||
// events — with ZERO model calls in replay (no replay fixture; a stray stream
|
||||
// fails loud on the open llm seam). The cold session also carries the one
|
||||
// keyless command-row surface: an Access-chip pick runs `/permission` on the
|
||||
// host, so the settled row's copy has a golden here. The seed is a recorded
|
||||
// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds
|
||||
// into its checkpoint, while an Access-chip pick later runs `/permission` on
|
||||
// the host. The seed is a recorded
|
||||
// fixture under the
|
||||
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
|
||||
// live through the composer (real read tool against seeded workspace files)
|
||||
@@ -39,18 +40,18 @@ const SEED_ID = 'seeded-history-web-e2e'
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
/**
|
||||
* Append a complete, valid compaction transaction over the recorded turn's own
|
||||
* surface. The recording stays model-authentic and reusable; replay adds this
|
||||
* deterministic condition before seeding it cold, so the scenario pins the bug
|
||||
* this change fixes — a landed compaction must not erase history the reader
|
||||
* already saw — through the real host and the real browser.
|
||||
* Append a complete manual `/compact` lifecycle and valid compaction transaction
|
||||
* over the recorded turn's own surface. The recording stays model-authentic and
|
||||
* reusable; replay adds this deterministic condition before seeding it cold, so
|
||||
* the scenario pins both the log-preserving marker and its single-card command
|
||||
* presentation through the real host and browser.
|
||||
* @param raw - the seed fixture text, already realized (placeholder-free) so
|
||||
* the shadow price below is computed from the exact strings the host folds.
|
||||
* @param meter - the composed token meter; the appended `compact/summary`'s
|
||||
* shadow price must be the exact heuristic price of the shadowed nodes, the
|
||||
* way compact-basic derives it, because the token-meter projections subtract
|
||||
* it verbatim.
|
||||
* @returns the fixture with a compacted turn appended.
|
||||
* @returns the fixture with a manual compaction lifecycle appended.
|
||||
*/
|
||||
function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
const lines = raw.trimEnd().split('\n')
|
||||
@@ -73,28 +74,32 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
if (first === undefined || last === undefined || tail === undefined) {
|
||||
throw new Error('seeded-history compaction requires a non-empty closed surface')
|
||||
}
|
||||
// The transaction opens the turn after the recording's last closed one; read
|
||||
// it from the fixture so a re-recording with a different turn count stays
|
||||
// valid instead of appending a duplicate turn number.
|
||||
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
|
||||
if (typeof lastTurn !== 'number') {
|
||||
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
|
||||
}
|
||||
const turn = lastTurn + 1
|
||||
let seq = tail.seq + 1
|
||||
let time = tail.time + 1
|
||||
/**
|
||||
* Append one event at the next seq/time.
|
||||
* @param event - the event body, without seq/time.
|
||||
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
|
||||
* @returns the assigned seq, so later `sourceEventSeqs` cite the pushed event directly.
|
||||
*/
|
||||
const at = (event: Record<string, unknown>): number => {
|
||||
const taken = seq++
|
||||
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
|
||||
return taken
|
||||
}
|
||||
at({ type: 'turn/start', data: { turn } })
|
||||
const startSeq = at({ type: 'compact/start', data: { turn } })
|
||||
const commandId = 'cmd-seeded-manual-compact'
|
||||
const compactionId = 'compact-seeded-manual-compact'
|
||||
at({
|
||||
type: 'command/run',
|
||||
data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
|
||||
})
|
||||
const startSeq = at({
|
||||
type: 'compact/start',
|
||||
data: { compactionId, sourceCommandId: commandId, turn: null },
|
||||
})
|
||||
// Load-bearing exactness: the projections subtract this count verbatim, so
|
||||
// it must equal what the host's fold prices for these nodes. The estimator
|
||||
// prices message CONTENT only, so a minimal wrapper per storage shape is
|
||||
@@ -123,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
const summarySeq = at({
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
compactionId,
|
||||
sourceCommandId: commandId,
|
||||
summary: [{
|
||||
type: 'text',
|
||||
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
|
||||
@@ -141,13 +148,31 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
source: {
|
||||
kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
|
||||
},
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: first, end: last },
|
||||
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
|
||||
})
|
||||
at({ type: 'compact/end', data: { turn } })
|
||||
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
at({
|
||||
type: 'compact/end',
|
||||
data: { compactionId, sourceCommandId: commandId, turn: null },
|
||||
})
|
||||
at({
|
||||
type: 'command/done',
|
||||
data: {
|
||||
commandId,
|
||||
kind: 'success',
|
||||
text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`,
|
||||
sourceEventSeq: summarySeq,
|
||||
},
|
||||
})
|
||||
// The persistence seed helper requires a terminal turn/end. Keep the manual
|
||||
// command standalone, then add a closed zero-step fixture boundary after it.
|
||||
const closureTurn = lastTurn + 1
|
||||
at({ type: 'turn/start', data: { turn: closureTurn } })
|
||||
at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } })
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
@@ -239,14 +264,18 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await sessionRow.click()
|
||||
// Settled barrier for history: the recorded final assistant text renders.
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
|
||||
timeout: 10_000,
|
||||
}).toBe(1)
|
||||
expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
|
||||
// Tool cards render from logged tool/call + tool/result alone (views are
|
||||
// host-recomputed per page; the generic card is the documented default).
|
||||
const toolRows = page.locator('[data-variant], [data-sample]')
|
||||
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// The bug this fixes: the compaction shadowed the whole recorded surface on
|
||||
// the model side, and the prompt and full tool output are still on screen.
|
||||
// The pinned hazard: compaction shadows the surface on the model side
|
||||
// only — the prompt and full tool output must stay on screen.
|
||||
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
|
||||
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
@@ -280,10 +309,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
|
||||
// This scenario deliberately leaves the LLM seam open to prove zero
|
||||
// model calls. History still restores the routed id, but without an
|
||||
// advertised catalog row the selector prompts for a listed replacement.
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
// This scenario issues zero model calls — the scaffold's route-only
|
||||
// adapter serves the catalog and refuses to stream — so history restores
|
||||
// the routed id and the seat resolves it against an advertised row.
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
@@ -363,7 +392,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
|
||||
const marker = page.getByRole('button', { name: /Context compacted/ })
|
||||
const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
|
||||
await marker.waitFor({ timeout: 10_000 })
|
||||
expect(await marker.getAttribute('aria-expanded')).toBe('false')
|
||||
await marker.click()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
|
||||
// -> 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.
|
||||
@@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it('flips the theme through the Appearance cubes and persists across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
||||
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
|
||||
await page.evaluate(() => ({
|
||||
interface ThemeState {
|
||||
attr: boolean
|
||||
background: string
|
||||
stored: string | null
|
||||
themeColor: string | null
|
||||
themeColorCount: number
|
||||
token: string
|
||||
}
|
||||
const readState = async (): Promise<ThemeState> => await page.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'),
|
||||
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
|
||||
background: computed.backgroundColor,
|
||||
stored: localStorage.getItem('dsh.theme'),
|
||||
}))
|
||||
themeColor: metas[0]?.content ?? null,
|
||||
themeColorCount: metas.length,
|
||||
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
|
||||
}
|
||||
})
|
||||
const expectThemeColorSynchronized = (state: ThemeState): void => {
|
||||
expect(state.themeColorCount).toBe(1)
|
||||
expect(state.background).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(state.themeColor).toBe(state.background)
|
||||
}
|
||||
// Pin the OS scheme to light so the default `system` preference resolves
|
||||
// light and the dark flip below is unambiguously the gesture's doing.
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
const light = await readState()
|
||||
expect(light.attr).toBe(false)
|
||||
expectThemeColorSynchronized(light)
|
||||
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(dark.attr).toBe(true)
|
||||
expect(dark.stored).toBe('dark')
|
||||
expect(dark.token).not.toBe(light.token)
|
||||
expectThemeColorSynchronized(dark)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Reload: the preference survives boot (restore + presenter initial apply).
|
||||
@@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const reloaded = await readState()
|
||||
expect(reloaded.attr).toBe(true)
|
||||
expect(reloaded.stored).toBe('dark')
|
||||
expectThemeColorSynchronized(reloaded)
|
||||
|
||||
// `system` follows the emulated OS scheme (dark stays dark, light clears).
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
@@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await systemCube.click()
|
||||
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
|
||||
expectThemeColorSynchronized(await readState())
|
||||
await page.emulateMedia({ colorScheme: 'dark' })
|
||||
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
|
||||
expectThemeColorSynchronized(await readState())
|
||||
// Restore for the specs that follow: light preference beats the emulated
|
||||
// dark OS scheme, leaving the shared page in the light default.
|
||||
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
|
||||
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
|
||||
expectThemeColorSynchronized(await readState())
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
@@ -244,7 +270,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await selector.click()
|
||||
await page.getByRole('menuitem', { name: 'English' }).click()
|
||||
// The settings-owned copy re-registers localized: dialog title, nav,
|
||||
// Appearance labels. (Only the settings namespaces are localized today —
|
||||
// Appearance labels. (Only the settings namespaces are localized —
|
||||
// the rest of the app's copy is intentionally out of this row's scope.)
|
||||
const enDialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await enDialog.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,7 @@ const EXPECTED_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'interrupt_agent',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
@@ -79,4 +82,19 @@ it('assembles the shipped Web catalog with the confined access default', async (
|
||||
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
|
||||
expect(scaffold.ctx.approval.config.policy).toBe('ask')
|
||||
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
|
||||
const handle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('shipped-command-catalog'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
try {
|
||||
expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({
|
||||
name: 'feedback',
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Web e2e scenario: the sidebar session list's scrollbar as the browser
|
||||
// actually lays it out — the observable half of the themed-scrollbar change
|
||||
// actually lays it out — the observable half of the themed scrollbars
|
||||
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
|
||||
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
|
||||
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
|
||||
@@ -17,8 +17,8 @@
|
||||
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
|
||||
// content, consuming no layout width unless something reserves space. That is
|
||||
// the mode in which the reported symptom exists at all, so this environment
|
||||
// reproduces it rather than merely approximating it — measured against clean
|
||||
// master, where the list's band is 0 and the bar covers 7px of the relative
|
||||
// reproduces it rather than merely approximating it — without either
|
||||
// declaration the list's band is 0 and the bar covers 7px of the relative
|
||||
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
|
||||
// the bar and nothing can be covered; a headed run under xvfb behaves that way
|
||||
// and cannot show the symptom.)
|
||||
@@ -40,9 +40,8 @@
|
||||
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
|
||||
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
|
||||
// so it abuts the timestamp without covering it; `band` catches that case.
|
||||
// Removing both — the actual master state — is what produces the reported
|
||||
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
|
||||
// the other assertions in its test silenced.
|
||||
// Removing both is what produces the reported overlap, and `timeCoveredBy`
|
||||
// measures it at 7.
|
||||
//
|
||||
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
|
||||
// to `transparent` while the pointer is outside the column), so every
|
||||
@@ -135,7 +134,8 @@ interface ListMetrics {
|
||||
/**
|
||||
* Measure the sidebar list in the page.
|
||||
* @param page - the page under test.
|
||||
* @returns the list's resolved scrollbar style and the geometry the fix changes.
|
||||
* @returns the list's resolved scrollbar style and the geometry the
|
||||
* scrollbar-gutter/thin-scrollbar declarations shape.
|
||||
*/
|
||||
function measureList(page: Page): Promise<ListMetrics> {
|
||||
return page.evaluate(() => {
|
||||
@@ -201,8 +201,8 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
// The bar is drawn in the rightmost `barWidth` of the border box, whether
|
||||
// or not that space was reserved. Its width comes from the sheet where the
|
||||
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
|
||||
// what this chromium paints, measured against master where the rule is
|
||||
// absent. Taking the UA width as the fallback is what keeps the assertion
|
||||
// what this chromium paints, measured with the rule absent. Taking the
|
||||
// UA width as the fallback is what keeps the assertion
|
||||
// honest: assuming 0 there would report no occlusion precisely in the
|
||||
// state that has it.
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
|
||||
@@ -261,13 +261,14 @@ async function measurePalette(page: Page): Promise<PaletteMetrics> {
|
||||
|
||||
/**
|
||||
* Render the golden body: the resolved scrollbar style of the list in each
|
||||
* palette, plus the geometric relations the fix establishes.
|
||||
* palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
|
||||
* declarations establish.
|
||||
*
|
||||
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
|
||||
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
|
||||
* committing them would make the golden fail on a machine whose fonts measure
|
||||
* differently — a fixture that has to be re-recorded per platform documents the
|
||||
* platform, not the change. What is recorded instead is the band, the overlap,
|
||||
* platform, not the behavior. What is recorded instead is the band, the overlap,
|
||||
* and the two orderings, each of which is a difference or a comparison and so
|
||||
* survives any layout that keeps the reservation.
|
||||
* @param light - metrics measured under the light palette.
|
||||
@@ -417,12 +418,13 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(metrics.scrollbarEdgeOffset).toBe(2)
|
||||
expect(metrics.rowEdgeInset).toBe(12)
|
||||
// The reported symptom, stated directly: no part of the row's relative time
|
||||
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
|
||||
// covered part. Unlike the client-edge comparison below it does not go
|
||||
// vacuous under an overlay scrollbar, because it measures against the bar's
|
||||
// own width rather than against a content edge the overlay bar does not
|
||||
// move. It is not a replacement for the band assertion above; see the file
|
||||
// header for which regression each one catches.
|
||||
// lies under the bar. Without either declaration it measures 7 — the `h`
|
||||
// of `1h` is the covered part. Unlike the client-edge comparison below it
|
||||
// does not go vacuous under an overlay scrollbar, because it measures
|
||||
// against the bar's own width rather than against a content edge the
|
||||
// overlay bar does not move. It is not a replacement for the band
|
||||
// assertion above; see the file header for which regression each one
|
||||
// catches.
|
||||
expect(metrics.timeCoveredBy).toBe(0)
|
||||
// Corollaries of the reservation, kept because they pin where the band sits
|
||||
// rather than only that it exists: the time ends inside the content area,
|
||||
@@ -451,7 +453,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(quiet.band).toBeGreaterThan(0)
|
||||
expect(quiet.timeCoveredBy).toBe(0)
|
||||
// Scrolling without a pointer — what a keyboard or a touch drag does —
|
||||
// leaves the column quiet. This is the change's one deliberate loss, and
|
||||
// leaves the column quiet. This is the one deliberate loss, and
|
||||
// it is pinned here rather than only described, so making a scroll
|
||||
// re-reveal the bar has to be a decision rather than a side effect.
|
||||
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
|
||||
|
||||
156
apps/web/tests/sidebar-subagent-activity.e2e.ts
Normal file
156
apps/web/tests/sidebar-subagent-activity.e2e.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionId as SessionIdValue } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import type {} from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-subagent-activity', import.meta.url))
|
||||
const RUNNING_OWNER_EXPECTED = join(SNAPSHOT_DIR, 'owner-running.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const HOLD_PROVIDER = 'web-test-hold'
|
||||
const HOLD_MODEL = 'hold'
|
||||
|
||||
/** Model stub that completes the owner turn, then holds its delegated child open. */
|
||||
class StagedAdapter extends LlmAdapter {
|
||||
activeCalls = 0
|
||||
private calls = 0
|
||||
|
||||
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (this.calls === 0) {
|
||||
this.calls += 1
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
return
|
||||
}
|
||||
this.calls += 1
|
||||
const signal = options.signal
|
||||
if (signal === undefined) throw new Error('staged Web adapter requires a turn signal')
|
||||
this.activeCalls += 1
|
||||
try {
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error('holding Web adapter aborted'))
|
||||
}
|
||||
if (signal.aborted) abort()
|
||||
else signal.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
} finally {
|
||||
this.activeCalls -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunningChild(
|
||||
scaffold: WebScaffold,
|
||||
adapter: StagedAdapter,
|
||||
childId: SessionIdValue,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (adapter.activeCalls !== 1 || scaffold.ctx.agents.get(childId)?.status !== 'running') {
|
||||
if (Date.now() >= deadline) throw new Error('held child did not enter its running model call')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: sidebar subagent activity', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let parentHandle: AgentHandle
|
||||
let childId: SessionIdValue
|
||||
let adapter: StagedAdapter
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
adapter = new StagedAdapter()
|
||||
scaffold.ctx.effect(
|
||||
() => scaffold.ctx.llm.registerAdapter([HOLD_PROVIDER], adapter),
|
||||
'sidebar subagent activity staged adapter',
|
||||
)
|
||||
const cwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
await mkdir(cwd)
|
||||
parentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('sidebar-activity-owner'),
|
||||
meta: { cwd },
|
||||
agentOptions: { provider: HOLD_PROVIDER, model: HOLD_MODEL },
|
||||
})
|
||||
parentHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Delegate a background task.' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await parentHandle.agent.whenIdle()
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'sidebar activity child',
|
||||
signal: new AbortController().signal,
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: 'Hold this delegated task open.' }],
|
||||
parent: parentHandle.agent,
|
||||
},
|
||||
})
|
||||
childId = started.childId
|
||||
await waitForRunningChild(scaffold, adapter, childId)
|
||||
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const workspace = await scaffold.ctx.workspace.resolveByPath(cwd)
|
||||
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
|
||||
await workspace.attachSession(parentHandle.agent.session.id)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
const child = childId === undefined ? undefined : scaffold?.ctx.agents.get(childId)
|
||||
if (child !== undefined) {
|
||||
child.cancel({ kind: 'user' })
|
||||
await child.whenIdle().catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await parentHandle?.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, 'sidebar subagent activity teardown failed')
|
||||
})
|
||||
|
||||
it('pins a running descendant on its visible idle owner row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-subagent-activity'))
|
||||
const sidebar = page.getByRole('tree', { name: 'Sessions' })
|
||||
const ownerRow = sidebar.getByRole('treeitem', { name: /1 subagent running Delegate a background task/ })
|
||||
await ownerRow.waitFor({ timeout: 10_000 })
|
||||
expect(parentHandle.agent.status).toBe('idle')
|
||||
await compareOrRefreshGolden(
|
||||
RUNNING_OWNER_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await ownerRow.click()
|
||||
const runningTrigger = page.getByRole('button', { name: '1 subagent running' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
// Web e2e scenario: the real host filters skill.list to the model-and-user
|
||||
// intersection before the browser slash source renders candidates. A real
|
||||
// Web e2e scenario: the real host serves every user-invocable skill to the
|
||||
// browser slash source — user-only (disable-model-invocation) entries appear
|
||||
// with their marker while user-disabled quadrants stay hidden. A real
|
||||
// chromium connects a fresh workspace seeded with all four policy quadrants;
|
||||
// no model call is issued, so a stray stream fails loud on the open LLM seam.
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
@@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders only the model-and-user intersection in slash candidates', async () => {
|
||||
it('renders every user-invocable skill and marks the user-only entry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/policy')
|
||||
@@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => {
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
// The user-only quadrant is invocable here — its only entry point — and
|
||||
// wears the user-only marker; both user-disabled quadrants stay hidden.
|
||||
expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1)
|
||||
expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
|
||||
80
apps/web/tests/skill-tool-row.e2e.ts
Normal file
80
apps/web/tests/skill-tool-row.e2e.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Web e2e scenario: the real skill-load recording, seeded cold through the
|
||||
// persistence seam, renders through ui-skill's keyed toolview without a model
|
||||
// call. The disclosure proves replay-stable naming and exact durable output.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/skill-load/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'skill-tool-row-web-e2e'
|
||||
const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('expands the loaded skill to its exact recorded instructions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row'))
|
||||
const call = page.locator('[data-tool="skill"]')
|
||||
const row = call.getByRole('button', { name: 'Skill snapshot-skill' })
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1)
|
||||
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
await call.getByText('Instructions', { exact: true }).waitFor()
|
||||
const output = call.locator('pre')
|
||||
await output.waitFor()
|
||||
expect(await output.textContent()).toContain('<skill_content name="snapshot-skill">')
|
||||
expect(await output.textContent()).toContain('Follow these snapshot-only instructions.')
|
||||
expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px')
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
150
apps/web/tests/skill-user-invoke.e2e.ts
Normal file
150
apps/web/tests/skill-user-invoke.e2e.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Web e2e scenario: a user invokes a disable-model-invocation skill through
|
||||
// the composer (issue #1470). The entered `/name args` line claims into
|
||||
// skill.invoke: the real host forwards the gesture as an ordinary user
|
||||
// prompt, injects the rendered body as instructions context named after the
|
||||
// skill, and starts a turn answered by the replay adapter. The transcript shows
|
||||
// the gesture bubble, the collapsed context-injection row, and the reply.
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const SKILL_NAME = 'user-invoke-demo'
|
||||
const ARGS_TEXT = 'and confirm the fixture wiring'
|
||||
const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
|
||||
|
||||
async function seedUserOnlySkill(workspaceCwd: string): Promise<void> {
|
||||
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'SKILL.md'), [
|
||||
'---',
|
||||
`name: ${SKILL_NAME}`,
|
||||
'description: Prove user-explicit invocation of a model-hidden skill',
|
||||
'disable-model-invocation: true',
|
||||
'---',
|
||||
'',
|
||||
'Reply with the fixture acknowledgement line.',
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const REPLAY: ReplayOverrideDoc = [{
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: REPLY },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: REPLY } },
|
||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}]
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(REPLAY))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
// Paced replay keeps the timing-derived chrome (TTFT / tok/s) present
|
||||
// deterministically; instant playback races it in and out of the golden.
|
||||
paceMs: 10,
|
||||
})
|
||||
await seedUserOnlySkill(scaffold.workspaceCwd)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed')
|
||||
})
|
||||
|
||||
it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
|
||||
const composer = page.locator('textarea:enabled').last()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
|
||||
// The menu lists the user-only skill (its only entry point) before enter.
|
||||
await composer.fill(`/${SKILL_NAME}`)
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await expect.poll(
|
||||
() => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
|
||||
await composer.press('Enter')
|
||||
|
||||
// The gesture stays an ordinary user bubble (decorated /name token plus
|
||||
// the trailing text), ahead of the injected context.
|
||||
const bubble = page.locator('[data-ref-chip="skill"]').first()
|
||||
await bubble.waitFor({ timeout: 15_000 })
|
||||
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
|
||||
|
||||
// The rendered body arrives as a context-injection row named after the
|
||||
// skill; expanding it reveals the canonical <skill_content> block, and
|
||||
// the user's text is NOT folded into it.
|
||||
const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
|
||||
await injectionRow.waitFor({ timeout: 15_000 })
|
||||
await injectionRow.click()
|
||||
const injectionBody = page
|
||||
.locator('[data-context-injection-body]')
|
||||
.filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
|
||||
await injectionBody.waitFor({ timeout: 10_000 })
|
||||
const injected = await injectionBody.textContent()
|
||||
expect(injected).toContain('Reply with the fixture acknowledgement line.')
|
||||
expect(injected).not.toContain(ARGS_TEXT)
|
||||
await injectionRow.click()
|
||||
|
||||
// The injection started a turn; the replay adapter answers it.
|
||||
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
|
||||
await settled
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
|
||||
// Real-host smoke: spawn `dsh web` with a real key, walk the full flow
|
||||
// list in a real chromium, screenshot every screen into .artifacts/ for the
|
||||
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
|
||||
// convention); vitest.web.config.ts loads the repo-root .env before this file
|
||||
@@ -11,8 +11,9 @@
|
||||
// (frame/handle) rides local names that survive hashing as suffixes; prefer
|
||||
// data-* for anything new.
|
||||
//
|
||||
// Flow order matters: chat rounds first (5 depends on 3's session), geometry
|
||||
// and theme after, reload recovery last. Tests run sequentially in-file.
|
||||
// Flow order matters: chat rounds first (the bash round reuses the first
|
||||
// send's session), geometry and theme after, reload recovery last. Tests run
|
||||
// sequentially in-file.
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -119,7 +120,7 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
|
||||
/** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
|
||||
async function screen(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
|
||||
}
|
||||
@@ -465,7 +466,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key)', () => {
|
||||
let child: ChildProcess
|
||||
let sessionsDir: string
|
||||
let baseUrl: string
|
||||
@@ -518,7 +519,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('1 cold start: loading page settles into the three-column frame', async () => {
|
||||
it('cold start: loading page settles into the three-column frame', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
|
||||
@@ -527,7 +528,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '01-cold-start')
|
||||
})
|
||||
|
||||
it('2+3 empty-state first send completes a real model round', async () => {
|
||||
it('empty-state first send completes a real model round', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
|
||||
// This scenario spawns its own server against a fresh $DSH_HOME, so the
|
||||
// first-run welcome notice is unacknowledged and its overlay owns pointer
|
||||
@@ -590,7 +591,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click leaves the default details column closed', async () => {
|
||||
it('bash differential rendering: tool row click leaves the default details column closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
@@ -604,12 +605,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await toolRow.click()
|
||||
// Tool rows no longer drive layout.openDetails; the default column stays closed.
|
||||
// Tool rows do not drive layout.openDetails; the default column stays closed.
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await screen(page, '09-details-closed')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and resets across reload', async () => {
|
||||
it('sidebar drag widens the column and resets across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
|
||||
const before = await firstTrack(page)
|
||||
const handle = page.locator('[class*="handle"]').first()
|
||||
@@ -627,10 +628,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
expect(await firstTrack(page)).toBe(before)
|
||||
})
|
||||
|
||||
it('7 dark mode: the body attribute cascades the token sheets', async () => {
|
||||
it('dark mode: the body attribute cascades the token sheets', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-dark'))
|
||||
// theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
|
||||
// in P-I, so the acceptance drives the documented mechanism directly.
|
||||
// The body attribute is the documented cascade mechanism; the Settings
|
||||
// gesture is owned by settings-chrome.e2e.ts — drive the attribute
|
||||
// directly here.
|
||||
const dark = await page.evaluate(() => {
|
||||
document.body.setAttribute('data-ds-dark-theme', '')
|
||||
return getComputedStyle(document.body).backgroundColor
|
||||
@@ -643,7 +645,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
expect(dark).not.toBe(light)
|
||||
})
|
||||
|
||||
it('8 reload recovery: history replays after a fresh boot', async () => {
|
||||
it('reload recovery: history replays after a fresh boot', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-reload'))
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
- menu "模型与推理等级":
|
||||
- menuitemradio "Default" [checked]:
|
||||
- text: Default
|
||||
- img
|
||||
- menuitemradio "Off"
|
||||
- menuitemradio "High"
|
||||
- menuitemradio "Max"
|
||||
@@ -1,6 +1,7 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Commands
|
||||
- option "compact Compact older conversation history" [selected]
|
||||
- option "feedback record feedback about this session"
|
||||
- option "goal set or view the goal for a long-running task"
|
||||
- option "permission Switch the permission preset (sandbox mode + approval policy)"
|
||||
- option "plan Enter or leave plan mode"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- text: Let's start building Preview
|
||||
- text: Into the Unknown Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- text: Let's start building Preview
|
||||
- text: Into the Unknown Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
@@ -30,8 +30,8 @@
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: Details
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
- img "API 密钥已配置"
|
||||
- button "编辑 minimax-cn": 编辑
|
||||
- button "删除 minimax-cn": 删除
|
||||
- listitem:
|
||||
- text: Acme Gateway 自定义
|
||||
- button "编辑 Acme Gateway (acme-gateway)": 编辑
|
||||
- button "删除 Acme Gateway (acme-gateway)": 删除
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
@@ -25,12 +25,6 @@
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://api.deepseek.com
|
||||
- text: 推理强度
|
||||
- combobox "推理强度":
|
||||
- option "默认" [selected]
|
||||
- option "off"
|
||||
- option "high"
|
||||
- option "max"
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
kind=matches
|
||||
summary=显示 9 / 共 42 处匹配 · 3 个文件
|
||||
file=packages/client/ui-primitives/src/SearchBlock.tsx3
|
||||
file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
|
||||
file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4
|
||||
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
line=138: export function SearchBlock(props: SearchBlockProps) {
|
||||
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
line=35: const search = searchCardModel(block)
|
||||
line=52: search={search}
|
||||
line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
line=36: const search = searchCardModel(block)
|
||||
line=56: search={search}
|
||||
line=78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
expand=… 其余 4 行
|
||||
recovery=Found 9 of 42 matches
|
||||
|
||||
@@ -15,13 +15,13 @@ packages/client/ui-primitives/src/SearchBlock.tsx
|
||||
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
Line 138: export function SearchBlock(props: SearchBlockProps) {
|
||||
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
packages/client/ui-conversation/src/client/contract/search-card-model.ts
|
||||
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
|
||||
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
packages/client/ui-conversation/src/client/toolviews/search-row.tsx
|
||||
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
Line 35: const search = searchCardModel(block)
|
||||
Line 52: search={search}
|
||||
Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
packages/client/ui-tool/src/client/tool/models/search-card-model.ts
|
||||
Line 45: export const CHAT_SEARCH_MAX_LINES = 8
|
||||
Line 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
|
||||
Line 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
Line 36: const search = searchCardModel(block)
|
||||
Line 56: search={search}
|
||||
Line 78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
|
||||
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)
|
||||
@@ -31,9 +31,7 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
|
||||
- button "Context injection AGENTS.md":
|
||||
- img
|
||||
- img
|
||||
@@ -44,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Read Only"': Read Only
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
@@ -31,9 +31,7 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
|
||||
- button "Context injection AGENTS.md":
|
||||
- img
|
||||
- img
|
||||
@@ -42,8 +40,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "1 subagent running Delegate a background task. now"
|
||||
- treeitem "New Session" [selected]
|
||||
@@ -1,3 +1,4 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Skills
|
||||
- option "policy-shared Available to both model and user invocation" [selected]
|
||||
- option "policy-user-only user-only · Available only to user invocation"
|
||||
|
||||
45
apps/web/tests/snapshots/skill-tool-row/ui.expected.md
Normal file
45
apps/web/tests/snapshots/skill-tool-row/ui.expected.md
Normal file
@@ -0,0 +1,45 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Load the snapshot-skill skill with" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Context injection skill-catalog":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection skill-catalog
|
||||
- button "Think Load the requested skill.":
|
||||
- img
|
||||
- img
|
||||
- text: Think Load the requested skill.
|
||||
- button "Skill snapshot-skill" [expanded]:
|
||||
- img
|
||||
- text: Skill snapshot-skill
|
||||
- region "Instructions": "Instructions <skill_content name=\"snapshot-skill\"> <skill_resources> Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. </skill_resources> <skill_instructions> Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. </skill_instructions> </skill_content>"
|
||||
- button "Inspect"
|
||||
- button "Think The skill is loaded.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The skill is loaded.
|
||||
- paragraph: DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok
|
||||
33
apps/web/tests/snapshots/skill-user-invoke/ui.expected.md
Normal file
33
apps/web/tests/snapshots/skill-user-invoke/ui.expected.md
Normal file
@@ -0,0 +1,33 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "/user-invoke-demo and confirm the fixtur" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Context injection user-invoke-demo":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection user-invoke-demo
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "0% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok
|
||||
@@ -0,0 +1,23 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Explain event sourcing in one sentence. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- 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 "Stop generating"
|
||||
- button "Send message" [disabled]
|
||||
@@ -3,10 +3,10 @@
|
||||
// A page load with a workspace already registered runs
|
||||
// `WorkspacesService.startInitialSelection`: it connects the most recent
|
||||
// workspace and opens its blank session. `openState` flips to `loading` the
|
||||
// moment `open()` lands, which used to drive `data-phase=settling` on the
|
||||
// conversation root — `visibility:hidden` over the composer seat and the
|
||||
// header for the whole `session.history` round-trip, so the center column went
|
||||
// blank and repainted, reading as a full-page refresh on every launch.
|
||||
// moment `open()` lands; driving `data-phase=settling` on the conversation
|
||||
// root from that flip would hide the composer seat and the header
|
||||
// (`visibility:hidden`) for the whole `session.history` round-trip — the
|
||||
// center column blanks and repaints like a full-page refresh on every launch.
|
||||
//
|
||||
// The unit spec pins the phase condition over hand-built stores. What only the
|
||||
// assembled application can show is that the path a user actually takes
|
||||
@@ -19,8 +19,9 @@
|
||||
// The round-trip against a loopback host is far too fast to observe, so this
|
||||
// scenario HOLDS the `session.history` response open at the browser's network
|
||||
// boundary and asserts the visible frame while it is in flight. That gate is
|
||||
// what makes the assertions non-vacuous: with the exemption reverted the held
|
||||
// window is exactly when `settling` is painted and the composer is hidden.
|
||||
// what makes the assertions non-vacuous: without the phase exemption, the
|
||||
// held window is exactly when `settling` would be painted and the composer
|
||||
// hidden.
|
||||
//
|
||||
// Zero model calls: registering a workspace and opening its blank session are
|
||||
// host RPCs with no model involvement. A stray stream would fail loud with
|
||||
@@ -145,7 +146,7 @@ describe('web e2e: startup auto-selection', () => {
|
||||
// seat with `visibility:hidden`, which Playwright reports as not visible).
|
||||
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
|
||||
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
|
||||
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
|
||||
expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
|
||||
expect(await page.locator('textarea').first().isVisible()).toBe(true)
|
||||
|
||||
releaseHistory()
|
||||
|
||||
@@ -365,22 +365,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
() => scaffold.ctx.agents.get(childId)?.status,
|
||||
{ timeout: 10_000 },
|
||||
).toBe('running')
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button').first().click()
|
||||
const runningTrigger = page.getByRole('button', { name: '3 subagents running' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await runningTrigger.click()
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
await ended
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*not running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByRole('button', { name: '3 subagents' })
|
||||
.locator('[data-state="ongoing"]').count()).toBe(0)
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||||
|
||||
319
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal file
319
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// Web e2e scenario: the composer's independent Stop interrupts a running
|
||||
// continuable child. The child holds its model turn open through a replay
|
||||
// hang entry; the browser proves Send and Stop coexist, the parent-offline
|
||||
// disabled-Send-with-Stop composer, the subagent.interrupt
|
||||
// (never session.cancel) transport, the parked follow-up, and the FIFO resume
|
||||
// on a waking send.
|
||||
//
|
||||
// Replay-binding note: only the PRIMARY script can hang, and scripts bind by
|
||||
// first-call order, so the child issues the composition's first model call
|
||||
// (claiming the overridden primary) and the parent's one UI prompt — needed
|
||||
// so the non-blank parent renders its header catalog — binds to a derived
|
||||
// child fixture afterwards.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-interrupt', import.meta.url))
|
||||
const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const LABEL = 'event-sourcing researcher'
|
||||
const INITIAL = 'Explain event sourcing in one sentence.'
|
||||
const REARM = 'Keep working until I stop you again.'
|
||||
const REARM_WAKE = 'Start that queued work now.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const WAKING = 'And add one concrete example.'
|
||||
const REARMED_ANSWER = 're-armed setup answer'
|
||||
const PARKED_ANSWER = 'parked follow-up answer'
|
||||
const WAKING_ANSWER = 'waking answer'
|
||||
|
||||
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
|
||||
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve on one exact child's next aborted turn end. */
|
||||
function waitForAbortedTurn(scaffold: WebScaffold, childId: SessionId): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error('interrupt did not reach an aborted turn/end'))
|
||||
}, 30_000)
|
||||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (session.id !== childId || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
if (event.data.reason.kind === 'aborted') resolve()
|
||||
else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
|
||||
function textCompletion(text: string): object {
|
||||
return {
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let sidecarRoot: string
|
||||
let rearmedReadyFile: string
|
||||
let parent: Agent
|
||||
let childId: SessionId
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-'))
|
||||
const readyFile = join(sidecarRoot, 'hang-ready')
|
||||
rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready')
|
||||
// The child claims this whole-script replacement: the offline and online
|
||||
// interrupt paths each hold one turn, then the parked and waking turns settle.
|
||||
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
|
||||
{ kind: 'hang', readyFile },
|
||||
{ kind: 'hang', readyFile: rearmedReadyFile },
|
||||
textCompletion(REARMED_ANSWER),
|
||||
textCompletion(PARKED_ANSWER),
|
||||
textCompletion(WAKING_ANSWER),
|
||||
]))
|
||||
await writeFile(
|
||||
join(sidecarRoot, 'session.jsonl'),
|
||||
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
|
||||
)
|
||||
// The parent's one prompted turn replays this recorded single text-only
|
||||
// call (binding is positional, not lineage-aware).
|
||||
const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl')
|
||||
const base = await readFile(BASE_FIXTURE, 'utf8')
|
||||
const [header, ...events] = base.trimEnd().split('\n')
|
||||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||||
await writeFile(parentTurnPath, [
|
||||
header
|
||||
.replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"')
|
||||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442'),
|
||||
...events,
|
||||
'',
|
||||
].join('\n'))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(sidecarRoot, 'session.jsonl'),
|
||||
replayOverride: join(sidecarRoot, 'replay.override.json'),
|
||||
replayChildFixtures: [parentTurnPath],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const root = scaffold.ctx.agents.roots()[0]
|
||||
if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||||
parent = root
|
||||
// The child's first model call claims the primary override and holds.
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: LABEL,
|
||||
signal: new AbortController().signal,
|
||||
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
|
||||
})
|
||||
childId = started.childId
|
||||
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
|
||||
|
||||
// One prompted parent turn makes the parent non-blank so the session
|
||||
// header (and its subagent catalog action) renders.
|
||||
const parentSettled = scaffold.whenTurnSettled()
|
||||
const parentInput = page.locator('textarea:enabled').first()
|
||||
await parentInput.fill('Ask a research subagent to explain event sourcing.')
|
||||
await parentInput.press('Enter')
|
||||
expect(await parentSettled).toBe(parent.id)
|
||||
|
||||
// Reload onto the restart baseline (the proven route to a freshly
|
||||
// discovered catalog), with the child still live and running host-side.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarRoot !== undefined) {
|
||||
await rm(sidecarRoot, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed')
|
||||
})
|
||||
|
||||
it('interrupts the live child through the parent-offline composer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
|
||||
// Simulate a parent that went offline: the catalog delivers
|
||||
// parentAvailable: false while the child Activation stays live (the
|
||||
// interrupt RPC itself needs no live parent — covered host-side by
|
||||
// subagent-interrupt.e2e.ts).
|
||||
const pattern = '**/api/subagent.list'
|
||||
await page.route(pattern, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const body = await response.json() as {
|
||||
result: { ok: true; value: { parentAvailable: boolean } } | { ok: false }
|
||||
}
|
||||
if (body.result.ok) body.result.value.parentAvailable = false
|
||||
await route.fulfill({ response, json: body })
|
||||
})
|
||||
try {
|
||||
await page.getByRole('button', { name: /1 subagent/ }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.getByRole('textbox', {
|
||||
name: 'Parent session offline; sending is unavailable but you can still stop the run',
|
||||
})
|
||||
await input.waitFor({ timeout: 15_000 })
|
||||
expect(await input.isDisabled()).toBe(true)
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
expect(await stop.isEnabled()).toBe(true)
|
||||
const send = page.getByRole('button', { name: 'Send message' })
|
||||
expect(await send.count()).toBe(1)
|
||||
expect(await send.isDisabled()).toBe(true)
|
||||
await compareOrRefreshGolden(
|
||||
OFFLINE_COMPOSER_EXPECTED,
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
|
||||
// Keep the continuable Activation resident after this first abort. The
|
||||
// direct setup queue does not change the parent-offline UI contract: its
|
||||
// input and Send remain disabled throughout the exercised browser path.
|
||||
await scaffold.ctx.subagents.followup(
|
||||
parent,
|
||||
childId,
|
||||
[{ type: 'text', text: REARM }],
|
||||
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
||||
)
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
}).result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
|
||||
await aborted
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
|
||||
|
||||
// Wake the parked setup message only after cancellation converges. A
|
||||
// second hang keeps the parent-available case independent from this stop.
|
||||
await scaffold.ctx.subagents.followup(
|
||||
parent,
|
||||
childId,
|
||||
[{ type: 'text', text: REARM_WAKE }],
|
||||
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
||||
)
|
||||
await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
|
||||
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
|
||||
} finally {
|
||||
await page.unroute(pattern)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
|
||||
// Reselect the child with the truthful catalog: parent available again.
|
||||
await page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
.getByRole('button').first().click()
|
||||
await page.getByRole('button', { name: /1 subagent/ }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||||
await input.waitFor({ timeout: 15_000 })
|
||||
expect(await input.isDisabled()).toBe(false)
|
||||
|
||||
// Queue a follow-up through Send while independent Stop remains available.
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
await input.fill(FOLLOWUP)
|
||||
await page.getByRole('button', { name: 'Send message' }).click()
|
||||
expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
|
||||
.toMatchObject({ ok: true })
|
||||
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
}).result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
// The addressed child stops through its own RPC, never the generic one.
|
||||
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
|
||||
await aborted
|
||||
|
||||
// Parked: the Activation stays resident and idle with the retained
|
||||
// follow-up; the primary returns to Send without a new turn starting.
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
|
||||
const child = scaffold.ctx.agents.get(childId)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.inbox.nextTurn).toHaveLength(2)
|
||||
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
|
||||
|
||||
// Only the waking send resumes the parked queue, FIFO, to settlement.
|
||||
await input.fill(WAKING)
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
expect(userTexts).toEqual([INITIAL, REARM, REARM_WAKE, FOLLOWUP, WAKING])
|
||||
const turnEndKinds = loaded.events
|
||||
.filter(event => event.type === 'turn/end')
|
||||
.map(event => event.data.reason.kind)
|
||||
expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md'])
|
||||
})
|
||||
})
|
||||
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal file
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
|
||||
// composition. A live continuable child holds its model turn open through a
|
||||
// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
|
||||
// proves from the real session state that the turn aborted, the follow-up
|
||||
// parked without auto-starting a new turn, and a later waking send resumed the
|
||||
// preserved FIFO order. No browser: the RPC surface is the product surface
|
||||
// under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const INITIAL = 'Explain event sourcing in one sentence.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const WAKING = 'And add one concrete example.'
|
||||
|
||||
type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** POST one unary RPC through the real HTTP carrier and unwrap its result. */
|
||||
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
|
||||
const response = await fetch(`${baseUrl}/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`,
|
||||
method,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json() as { result: RpcResult<T> }).result
|
||||
}
|
||||
|
||||
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
|
||||
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
|
||||
function textCompletion(text: string): object {
|
||||
return {
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
|
||||
let scaffold: WebScaffold
|
||||
let sidecarRoot: string
|
||||
let readyFile: string
|
||||
let parentId: SessionId
|
||||
let childId: SessionId
|
||||
|
||||
beforeAll(async () => {
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-'))
|
||||
readyFile = join(sidecarRoot, 'hang-ready')
|
||||
// Whole-script replacement: the child's three model calls are the hang
|
||||
// (turn 1, interrupted), the parked follow-up's turn, and the waking turn.
|
||||
// The parent never runs a turn, so the child claims this primary script.
|
||||
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
|
||||
{ kind: 'hang', readyFile },
|
||||
textCompletion('resumed response one'),
|
||||
textCompletion('resumed response two'),
|
||||
]))
|
||||
// Header-only primary fixture: the bare-array override replaces the
|
||||
// derived script entirely; the path only anchors replay installation.
|
||||
await writeFile(
|
||||
join(sidecarRoot, 'session.jsonl'),
|
||||
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
|
||||
)
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(sidecarRoot, 'session.jsonl'),
|
||||
replayOverride: join(sidecarRoot, 'replay.override.json'),
|
||||
})
|
||||
|
||||
// A live parent Agent through the real API; no workspace or browser.
|
||||
const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', {
|
||||
cwd: scaffold.workspaceCwd,
|
||||
})
|
||||
if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)
|
||||
parentId = sessionId(created.value.sessionId)
|
||||
const parent = scaffold.ctx.agents.get(parentId)
|
||||
if (parent === undefined) throw new Error('created parent session did not publish a live Agent')
|
||||
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'event-sourcing researcher',
|
||||
signal: new AbortController().signal,
|
||||
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
|
||||
})
|
||||
childId = started.childId
|
||||
// The hang entry writes readyFile after its prefix chunks, immediately
|
||||
// before waiting for cancellation: the deterministic "turn is open" gate.
|
||||
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
await rm(sidecarRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt teardown failed')
|
||||
})
|
||||
|
||||
it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
|
||||
// Queue the follow-up while the turn is still open, then interrupt.
|
||||
const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: FOLLOWUP }],
|
||||
})
|
||||
expect(queued).toMatchObject({ ok: true })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
})
|
||||
expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
// accepted acknowledges the admitted cancel, not quiescence: wait for the
|
||||
// aborted turn/end (the composition's first turn/end) before asserting.
|
||||
expect(await settled).toBe(childId)
|
||||
|
||||
// Parked, not resumed: the Activation stays resident with an idle driver,
|
||||
// the follow-up is retained, and no second turn opened.
|
||||
const child = scaffold.ctx.agents.get(childId)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.status).toBe('idle')
|
||||
expect(child!.inbox.nextTurn).toHaveLength(1)
|
||||
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1)
|
||||
expect((lastEnd)?.data.reason.kind).toBe('aborted')
|
||||
|
||||
// Only an explicit waking send resumes the parked queue, FIFO, then the
|
||||
// child runs both turns to completion and settles.
|
||||
const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: WAKING }],
|
||||
})
|
||||
expect(waking).toMatchObject({ ok: true })
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
// Human-origin messages only: the real composition also injects
|
||||
// runtime-context snapshots as non-user-source messages.
|
||||
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING])
|
||||
const turnEndKinds = loaded.events
|
||||
.filter(event => event.type === 'turn/end')
|
||||
.map(event => (event).data.reason.kind)
|
||||
expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed'])
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
|
||||
// client.js` bundles through AppWebEntry's ModuleLoader path against the
|
||||
// keyless FixtureApiClient transport, opens the fixture session, and pins the
|
||||
// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`)
|
||||
// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`)
|
||||
// reaches — the `todo_write` tool row and the dock's plan strip.
|
||||
//
|
||||
// The row is pinned as three separate fields on purpose. `summary=` is the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenario: assistant IconActions belong to the settled answer, so
|
||||
// they arrive with `turn/end` and not before. The recorded turn narrates in
|
||||
// plain text before its tool call, which is the shape that used to hand the
|
||||
// plain text before its tool call, which is the shape that would hand the
|
||||
// footer to mid-turn narration for the seconds a tool runs and then move it
|
||||
// down. A `hang` sidecar on the SECOND model call parks the turn after the
|
||||
// narration and the tool result are durable, so the running state is stable by
|
||||
|
||||
@@ -91,8 +91,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
).not.toBeUndefined()
|
||||
// First adoption births a blank Session+Agent whose workspace attach must
|
||||
// settle before a test may delete the registration; re-registration after
|
||||
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
|
||||
// path is gone), so callers opt in only where a fresh attach is possible.
|
||||
// a delete mints a fresh blank Session+Agent too (no cwd-based reuse
|
||||
// exists), so callers opt in only where a fresh attach is possible.
|
||||
if (options.waitForAgent === true) {
|
||||
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(agentsBefore)
|
||||
@@ -254,7 +254,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
// a supported reversible flow. It creates a fresh Workspace id and does
|
||||
// NOT re-adopt the retained (non-blank) Session; the New Session flow
|
||||
// mints a fresh blank session and attaches it to the new registration
|
||||
// (the old cwd-only blank reuse is gone, so the account is never empty).
|
||||
// (no cwd-based blank reuse exists, so the account is never empty).
|
||||
await adoptDirectory(scaffold.workspaceCwd)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
|
||||
@@ -482,7 +482,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
|
||||
// The card is REACHABLE: it sits 8px off the row, so getting to it means
|
||||
// crossing ground that belongs to neither. Hovering it must not dismiss
|
||||
// it — the regression this scenario guards.
|
||||
// it — the hazard this scenario pins.
|
||||
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
|
||||
await card.hover()
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
@@ -515,10 +515,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
const item = page.getByRole('menuitem', { name: 'Rename' })
|
||||
await item.waitFor({ timeout: 5_000 })
|
||||
// Into the list, then back up to the trigger across the 4px gap below it:
|
||||
// that return trip used to fire the list's pointerleave and close the
|
||||
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
|
||||
// the pointer ON the trigger, so entering the list has to come first for
|
||||
// the return to be a real departure.
|
||||
// without the gap-crossing grace, that return trip fires the list's
|
||||
// pointerleave and closes the menu — a hesitating pointer loses it.
|
||||
// Order matters — clicking leaves the pointer ON the trigger, so entering
|
||||
// the list has to come first for the return to be a real departure.
|
||||
await item.hover()
|
||||
await page.waitForTimeout(POINTER_TRANSIT_MS)
|
||||
await trigger.hover()
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/default-model.e2e.ts",
|
||||
"tests/declared-reasoning.e2e.ts",
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/remote-welcome.e2e.ts",
|
||||
"tests/workspace-management.e2e.ts",
|
||||
@@ -56,13 +58,20 @@
|
||||
"tests/markdown-inline-code-links.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
"tests/skill-user-invoke.e2e.ts",
|
||||
"tests/permission-policy-context.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/produced-files.e2e.ts",
|
||||
"tests/produced-file-mentions.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/subagent-interrupt.e2e.ts",
|
||||
"tests/subagent-interrupt-ui.e2e.ts",
|
||||
"tests/sidebar-subagent-activity.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts",
|
||||
"tests/skill-tool-row.e2e.ts",
|
||||
"tests/turn-tail-actions.e2e.ts",
|
||||
"tests/goal-multi-turn-actions.e2e.ts",
|
||||
"tests/chat-scroll-fixture.ts",
|
||||
|
||||
@@ -73,10 +73,13 @@ const BOOT_GRAMMAR_FILES: readonly string[] = [
|
||||
'dist/json.mjs',
|
||||
]
|
||||
|
||||
/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces today). */
|
||||
/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */
|
||||
const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf']
|
||||
|
||||
/** npm package name of a resolved module id (the segment after the LAST `node_modules/` — pnpm nests the real package under an inner node_modules). */
|
||||
/**
|
||||
* npm package name of a resolved module id (the segment after the LAST
|
||||
* `node_modules/` — pnpm nests the real package under an inner node_modules).
|
||||
*/
|
||||
function npmPackageOf(id: string): string | undefined {
|
||||
const parts = id.split('/node_modules/')
|
||||
if (parts.length === 1) return undefined
|
||||
@@ -94,7 +97,7 @@ export default defineConfig({
|
||||
output: {
|
||||
// Output layout: the two main chunks stay at assets/ root; lazy
|
||||
// @shikijs/langs grammar chunks group under assets/langs/; fonts
|
||||
// (today all KaTeX faces referenced by vendor.css) group under
|
||||
// (all KaTeX faces referenced by vendor.css) group under
|
||||
// assets/fonts/. Sourcemaps need no arrangement: rollup writes each
|
||||
// .map next to its js and references it by bare relative filename.
|
||||
chunkFileNames(chunk): string {
|
||||
@@ -128,7 +131,8 @@ export default defineConfig({
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
// so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
|
||||
// Only the shell's normal-package surface is aliased — plugin packages are
|
||||
// NEVER bundled here (web2 shell self-sufficiency); they arrive as runtime
|
||||
// NEVER bundled here (shell self-sufficiency — see
|
||||
// packages/client/web/README.md); they arrive as runtime
|
||||
// bundles through the client module system. Order matters — subpath
|
||||
// aliases must win over bare-name prefixes.
|
||||
alias: [
|
||||
|
||||
Reference in New Issue
Block a user