Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Resolve additive conflicts in the api-remotes client assembly by keeping both the message-feedback remote mount and master's forwarded-event allowlist, and regenerate the module graph.
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
|
||||
Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it.
|
||||
|
||||
Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy.
|
||||
Presets you author live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`; the roster reports each preset's real path, so take the one you edit from there. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy.
|
||||
|
||||
Load the `editing-cordis-compositions` skill before writing or changing a composition.
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
---
|
||||
name: editing-cordis-compositions
|
||||
description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing.
|
||||
description: Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing.
|
||||
---
|
||||
|
||||
# Editing Cordis compositions
|
||||
|
||||
Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.
|
||||
|
||||
## Off-limits
|
||||
|
||||
**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.
|
||||
|
||||
To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.
|
||||
|
||||
## Decide the plane first
|
||||
|
||||
Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared.
|
||||
@@ -17,16 +23,105 @@ Two planes, and the choice is not about how "agent-related" something feels —
|
||||
|
||||
**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.
|
||||
|
||||
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<name>/`.
|
||||
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.
|
||||
|
||||
Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. Both roots are configuration rather than fixed locations, though, and no call reports them — `authorable` says only whether a writable one exists — so take the path you actually read or edit from `list()` or `resolve()`, which is also where `copy()` reports what it just created.
|
||||
|
||||
## The roster service
|
||||
|
||||
`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.
|
||||
|
||||
Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on:
|
||||
|
||||
- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.
|
||||
- `read(id)` — one preset's composition text, without a file tool or a path.
|
||||
- `copy(from, id, name?)` — the only authoring write (see below).
|
||||
- `standingKeyFor(id)` — mount-validate one preset (see below).
|
||||
|
||||
```js
|
||||
return {
|
||||
name: 'preset-tools',
|
||||
inject: ['agentPresets', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'preset_check',
|
||||
description: 'Mount-validate one preset by id.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },
|
||||
async execute(args) {
|
||||
try {
|
||||
await ctx.agentPresets.standingKeyFor(args.id)
|
||||
return 'mounted OK'
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.
|
||||
|
||||
## Authoring a preset
|
||||
|
||||
1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.
|
||||
2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands.
|
||||
3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster.
|
||||
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above.
|
||||
1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.
|
||||
2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.
|
||||
3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.
|
||||
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.
|
||||
5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.
|
||||
|
||||
### Native product subagents
|
||||
A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.
|
||||
|
||||
## The rule that catches people
|
||||
|
||||
**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.
|
||||
|
||||
Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:"services"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.
|
||||
|
||||
When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:
|
||||
|
||||
```yaml
|
||||
- id: delegation
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
workflows: true
|
||||
config:
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
```
|
||||
|
||||
`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.
|
||||
|
||||
A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.
|
||||
|
||||
Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-tasks`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:
|
||||
|
||||
- a row whose package does not resolve (`Cannot find package …`);
|
||||
- a row whose config is invalid (`invalid config: $.<field> missing required value`);
|
||||
- a row that never activated (`N row(s) did not activate: <id>: waiting for <service>`);
|
||||
- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) [<name>]; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service "<name>" has been registered at <Owner>`. Both name the offending service.
|
||||
|
||||
It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.
|
||||
|
||||
**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.
|
||||
|
||||
`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.
|
||||
|
||||
After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.
|
||||
|
||||
`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.
|
||||
|
||||
## Native product subagents
|
||||
|
||||
Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field.
|
||||
|
||||
@@ -54,43 +149,6 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o
|
||||
|
||||
The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product.
|
||||
|
||||
The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete.
|
||||
|
||||
## The rule that catches people
|
||||
|
||||
**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.
|
||||
|
||||
Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service.
|
||||
|
||||
When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm:
|
||||
|
||||
```yaml
|
||||
- id: tasks
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
tasks: true
|
||||
config:
|
||||
- id: tasks-local
|
||||
name: '@deepseek-ai/dsh-tasks-local'
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
```
|
||||
|
||||
`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate.
|
||||
|
||||
A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing.
|
||||
|
||||
Host capabilities exposed through registries need no realm: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it.
|
||||
|
||||
To check a preset you authored, re-read the files and validate these fields: the top level is a YAML list, every row is a map with a `name`, every group carries its own list, and service-publishing rows sit behind an `isolate` realm. The settings page's preset roster validates the same fields and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself.
|
||||
|
||||
`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.
|
||||
|
||||
## What not to move into a preset
|
||||
|
||||
`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"@deepseek-ai/dsh-agent-tool-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-base": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
@@ -47,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-time-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
@@ -61,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-schedule": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
|
||||
@@ -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: cef687dc392f97886ea18b162e8f668a44ce2284
|
||||
README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82
|
||||
README.md: 46ea3c241d6775ce90a89c7be58901375a0634a3
|
||||
README.zh.md: f020f46260d6b04b87a4918a671ca6bcbed251d9
|
||||
|
||||
@@ -24,7 +24,7 @@ The shipped apps own these command lines:
|
||||
|
||||
| Profile | Arguments |
|
||||
|---|---|
|
||||
| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` |
|
||||
| `web` | `--host`, `--port`, repeatable `--trusted-host` |
|
||||
| `headless` | the task text, as the positional argument |
|
||||
|
||||
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
@@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
|
||||
|
||||
## Web alias
|
||||
|
||||
`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
|
||||
`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, and repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities). The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
@@ -63,9 +63,9 @@ dsh web --help
|
||||
|
||||
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
|
||||
|
||||
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup.
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers.
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
| Profile | 参数 |
|
||||
|---|---|
|
||||
| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` |
|
||||
| `web` | `--host`、`--port`、可重复的 `--trusted-host` |
|
||||
| `headless` | 任务文本,作为位置参数 |
|
||||
|
||||
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
@@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
|
||||
|
||||
## Web 别名
|
||||
|
||||
`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
|
||||
`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
@@ -63,9 +63,9 @@ dsh web --help
|
||||
|
||||
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
|
||||
|
||||
进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
|
||||
进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空——`SIGTERM` 是监督进程的普通停止请求,在所有 surface 上以 0 退出,`SIGINT` 报告 130;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
|
||||
|
||||
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。
|
||||
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次 profile 启动都监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器。
|
||||
|
||||
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im
|
||||
const USER_PRESET_DIR = '.agent-presets'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
import { resolveWindowsShellLayer } from './windows-shell.ts'
|
||||
|
||||
@@ -60,9 +59,6 @@ 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: its presence means this composition exits by itself. */
|
||||
const HEADLESS_ROW_ID = 'headless-runner'
|
||||
|
||||
/** The empty root entry list every profile tree patches over. */
|
||||
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
|
||||
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
|
||||
@@ -193,9 +189,20 @@ export interface RunProfileOptions {
|
||||
args: readonly string[]
|
||||
}
|
||||
|
||||
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
|
||||
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
|
||||
if (!signal.aborted) throw error
|
||||
/**
|
||||
* Re-throw a watcher-setup failure unless a shutdown already owns the tree:
|
||||
* a signal aborted this invocation, or an app requested exit (`ctx.appExit`
|
||||
* from a fast one-shot) and the root's disposal rejected the in-flight setup
|
||||
* await. Either way the failure describes a tree that is exiting as asked,
|
||||
* not a broken watch.
|
||||
* @param ctx - the booted root context.
|
||||
* @param signal - this invocation's signal-shutdown fact.
|
||||
* @param error - the setup failure.
|
||||
*/
|
||||
function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
|
||||
if (signal.aborted) return
|
||||
if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
|
||||
throw error
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,11 +213,6 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void
|
||||
*/
|
||||
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
|
||||
const composed = composeProfile(options.profile, options.patchFiles)
|
||||
// A one-shot composition ends by itself, which changes what a signal means
|
||||
// and makes watching the user's patch layer pointless.
|
||||
const headlessRow = composed.rows.get(HEADLESS_ROW_ID)
|
||||
const oneShot = headlessRow !== undefined && headlessRow.disabled !== true
|
||||
|
||||
const app: { current?: Context } = {}
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
const signalShutdown = new AbortController()
|
||||
@@ -220,7 +222,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
}
|
||||
// Signals own teardown throughout the startup window, not only after boot()
|
||||
// settles: an inserted provider can publish before sibling rows finish mounting.
|
||||
process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) })
|
||||
// SIGTERM is a supervisor's ordinary stop request and exits 0 on every
|
||||
// surface — the launcher does not know whether the app considered its work
|
||||
// complete; SIGINT is a user interrupt and reports 130.
|
||||
process.on('SIGTERM', () => { interrupt(0) })
|
||||
process.on('SIGINT', () => { interrupt(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
@@ -246,9 +251,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
|
||||
...composed.overlays,
|
||||
])
|
||||
// One-shot runs exit through the runner; watching would only hold the
|
||||
// process open after its exit request.
|
||||
const watchProfilePatch = !oneShot
|
||||
// Cloned for the same insert-aliasing reason as composeLive: the boot
|
||||
// application must not mutate the objects later reloads recompose from.
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
|
||||
@@ -262,22 +264,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
args: options.args,
|
||||
exit: code => void shutdown.shutdown(code),
|
||||
})
|
||||
if (oneShot) {
|
||||
const io: HeadlessIo = {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
exit: (code) => { void shutdown.shutdown(code) },
|
||||
}
|
||||
hostCtx.provide('headlessIo', io)
|
||||
}
|
||||
})
|
||||
app.current = ctx
|
||||
// A surface can dispose the whole tree while boot or this post-boot watcher
|
||||
// setup is still in flight. Loader presence and fiber state own
|
||||
// liveness; the local signal fact distinguishes that expected exit race
|
||||
// from a real HMR error.
|
||||
if (watchProfilePatch
|
||||
&& !signalShutdown.signal.aborted
|
||||
// setup is still in flight — a signal, or a fast one-shot's appExit. Loader
|
||||
// presence and fiber state own liveness; the initial check skips a tree
|
||||
// that already exited, and the catch below re-checks for an exit that
|
||||
// landed mid-setup. Watching is unconditional: a one-shot surface exits
|
||||
// through its bounded shutdown, which disposes the watchers before the
|
||||
// loop drains.
|
||||
if (!signalShutdown.signal.aborted
|
||||
&& ctx.fiber.state === FiberState.ACTIVE
|
||||
&& ctx.get('loader') !== undefined) {
|
||||
try {
|
||||
@@ -305,7 +301,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
compose: composeLive,
|
||||
})
|
||||
} catch (error) {
|
||||
suppressSignalShutdownError(signalShutdown.signal, error)
|
||||
suppressShutdownError(ctx, signalShutdown.signal, error)
|
||||
}
|
||||
}
|
||||
return { ctx, shutdown }
|
||||
|
||||
@@ -79,6 +79,9 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
|
||||
{ id: 'skill-badge', disabled: false },
|
||||
{ id: 'modules', disabled: true },
|
||||
{ id: 'connection', disabled: true },
|
||||
// The always-on reload chain waits for the browser roster and bound port
|
||||
// disabled above.
|
||||
{ id: 'client-hmr', disabled: true },
|
||||
// The shipped `-auto` chooser resolves its interaction from a running
|
||||
// host and so waits for the webserver disabled above; the browse variant
|
||||
// supplies `directoryPicker` without one.
|
||||
|
||||
@@ -70,51 +70,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/bash/tool-bash"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/hmr"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-settings-general"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-models"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-permission"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/locale"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-plan"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-trajectory"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-question"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
apps/web/tests/README.i18n.yaml
Normal file
6
apps/web/tests/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
||||
README.md: 68e5db5af5f816cc982bacb7989d996c859be204
|
||||
README.zh.md: f366c28024dab89d0243a60d93a706f220fa8fb8
|
||||
46
apps/web/tests/README.md
Normal file
46
apps/web/tests/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# apps/web browser e2e
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
These tests boot the real web composition in-process and drive it with a real
|
||||
Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and
|
||||
the deliberate composition divergences from `dsh web` — are documented in
|
||||
[`scaffold.ts`](scaffold.ts) and the
|
||||
[browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
|
||||
## These are Host-face tests
|
||||
|
||||
They type-check in the root `tsconfig.host.json`, not in the Client aggregate,
|
||||
because they read Host services directly: `ctx.apiProxy`, the Host
|
||||
`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does
|
||||
not make a file part of the Client program — the two faces merge cordis
|
||||
`Context` under the same keys with different services, so one program cannot see
|
||||
both. Moving these files into the Client aggregate makes every Host-service
|
||||
access fail to compile.
|
||||
|
||||
## Do not import `@deepseek-ai/dsh-client-*` here
|
||||
|
||||
Importing a Client package — a value or a type — pulls its whole TypeScript
|
||||
project, and every project it references, into the **Host build graph**. That has
|
||||
bitten this lane once already: four Client consumer packages reference
|
||||
`api/remotes`' Client face, which cannot compile until Host tsdown has generated
|
||||
`@deepseek-ai/dsh-goal/remote`, so the Host build phase ended up waiting on an
|
||||
artifact it produces itself.
|
||||
|
||||
When a scenario needs a Client-owned constant or pure function, mirror it here
|
||||
instead, next to the commented-out import that names the source module. A drift
|
||||
then surfaces as a missed selector or an unsuppressed notice — a loud failure,
|
||||
never a silent pass. `scaffold.ts` holds the mirrored welcome-notice values and
|
||||
exports them for the scenarios that assert on them.
|
||||
|
||||
Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
|
||||
it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest
|
||||
type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what
|
||||
that harness is for, and both packages are already in the Host graph. Separately,
|
||||
the chat scenarios import `conversationContextKey` from
|
||||
`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable
|
||||
through the unsplit `directory-picker` packages and pulls nothing further in.
|
||||
That reachability is incidental, not a guarantee — if it ever leaves the graph,
|
||||
mirror the helper like the rest.
|
||||
|
||||
Nothing mechanically enforces this rule; keep it in review.
|
||||
37
apps/web/tests/README.zh.md
Normal file
37
apps/web/tests/README.zh.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# apps/web 浏览器 e2e
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane
|
||||
的运行机制——模式、fixture、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在
|
||||
[`scaffold.ts`](scaffold.ts) 和
|
||||
[浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)中。
|
||||
|
||||
## 这些是 Host 面的测试
|
||||
|
||||
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取
|
||||
Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore`、`ctx.sessionProjectionCache`。运行时驱动
|
||||
浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis
|
||||
`Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处
|
||||
Host 服务访问都无法编译。
|
||||
|
||||
## 不要在此 import `@deepseek-ai/dsh-client-*`
|
||||
|
||||
import 一个 Client 包——无论值还是类型——都会把它整个 TypeScript 工程、以及它引用的每个工程
|
||||
拉进 **Host 构建图**。这已经坑过本 lane 一次:四个 Client 消费方包引用了 `api/remotes` 的
|
||||
Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 之后才能编译,
|
||||
于是 Host 构建阶段变成在等一个由它自己产出的产物。
|
||||
|
||||
当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
|
||||
import 点明源模块。这样漂移会表现为选择器未命中或提示未被抑制——是响亮的失败,绝不会是静默
|
||||
通过。`scaffold.ts` 持有镜像的 welcome-notice 取值,并导出给断言它们的场景使用。
|
||||
|
||||
有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
|
||||
`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从
|
||||
`@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该
|
||||
harness 的用途,且这两个包本来就在 Host 图中。另外,chat 场景从
|
||||
`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为
|
||||
`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是
|
||||
偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。
|
||||
|
||||
没有任何机制强制这条规则;靠 review 守住它。
|
||||
@@ -20,13 +20,18 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
|
||||
{ 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 },
|
||||
// The settings domain base: the only provider of ctx.settingsScope, which the
|
||||
// locale and ui-theme rows below inject for their preference rows. Without it
|
||||
// both stay pending and ui-layout never activates, so nothing renders.
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings', bundlePath: 'packages/client/ui-settings/lib/client.js', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-api-remotes'], 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', '@deepseek-ai/dsh-api-gateway'], 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: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], 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-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
bundlePath: 'packages/client/ui-workspace/lib/client.js',
|
||||
|
||||
123
apps/web/tests/goal-command-presentation.e2e.ts
Normal file
123
apps/web/tests/goal-command-presentation.e2e.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Web e2e: /goal opts its command input into the human transcript while the
|
||||
// command remains log-only. The shipped composition runs with no model adapter,
|
||||
// so an accidental turn fails loud in addition to the event-level assertions.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, 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/goal-command-presentation', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL(
|
||||
'./snapshots/goal-command-presentation/ui.expected.md', import.meta.url,
|
||||
))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: /goal human transcript presentation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const events: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('shows the bare input and result from a fresh session without a model turn', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation'))
|
||||
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
|
||||
timeout: 15_000,
|
||||
}).toBe(1)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/goal')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => input.inputValue()).toBe('/goal ')
|
||||
await input.press('Enter')
|
||||
|
||||
const commandInput = page.locator('[data-command-input]')
|
||||
await commandInput.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => commandInput.textContent()).toBe('/goal')
|
||||
expect(await commandInput.getAttribute('role')).toBe('group')
|
||||
expect(await commandInput.getAttribute('aria-label')).toBe('Command input')
|
||||
expect(await commandInput.getByRole('button').count()).toBe(0)
|
||||
const typography = await commandInput.evaluate((element) => {
|
||||
const bubble = element.firstElementChild?.firstElementChild
|
||||
if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
|
||||
const rootStyle = getComputedStyle(element)
|
||||
const bubbleStyle = getComputedStyle(bubble)
|
||||
return {
|
||||
fontFamily: bubbleStyle.fontFamily,
|
||||
parentFontFamily: rootStyle.fontFamily,
|
||||
fontSize: bubbleStyle.fontSize,
|
||||
lineHeight: bubbleStyle.lineHeight,
|
||||
}
|
||||
})
|
||||
expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
|
||||
expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
|
||||
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
|
||||
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)
|
||||
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
|
||||
expect(await page.getByText('Into the Unknown', { exact: false }).count()).toBe(0)
|
||||
|
||||
const run = events.find(event => event.type === 'command/run')
|
||||
expect(run).toMatchObject({
|
||||
type: 'command/run',
|
||||
data: { name: 'goal', args: ' ', source: { kind: 'user' } },
|
||||
})
|
||||
expect(events.some(event => event.type === 'command/done')).toBe(true)
|
||||
expect(events.some(event => event.type === 'user/message')).toBe(false)
|
||||
expect(events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(events.some(event => event.type === 'step/start')).toBe(false)
|
||||
expect(events.some(event => event.type === 'request/header')).toBe(false)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('reloads the same bubble and result from the persisted command lifecycle', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation-reload'))
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
|
||||
await expect.poll(() => page.locator('[data-command-input]').textContent(), { timeout: 15_000 }).toBe('/goal')
|
||||
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
|
||||
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
|
||||
|
||||
const sessions = scaffold.ctx.sessions.list()
|
||||
expect(sessions).toHaveLength(1)
|
||||
const persisted = sessions[0]?.events ?? []
|
||||
expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done')
|
||||
.map(event => event.type)).toEqual(['command/run', 'command/done'])
|
||||
expect(persisted.some(event => event.type === 'user/message')).toBe(false)
|
||||
expect(persisted.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(persisted.some(event => event.type === 'step/start')).toBe(false)
|
||||
expect(persisted.some(event => event.type === 'request/header')).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
|
||||
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -92,14 +92,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
|
||||
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
|
||||
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
|
||||
host = subprocessCtx.subprocess.spawn(spawnSpec(
|
||||
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
|
||||
[process.execPath, binPath, 'web', '--port', '0'],
|
||||
world,
|
||||
{
|
||||
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
|
||||
DSH_HOME: join(world, '.dsh'),
|
||||
},
|
||||
))
|
||||
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
|
||||
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web')
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
const pageErrors: string[] = []
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Opens the fixture history session whose turn 72 carries an image in BOTH a
|
||||
// user message and an assistant message, and pins the product surfaces: the
|
||||
// history ImageGallery loading real fixture bytes through the authorized
|
||||
// sessions.attachment route, the double-click ImageLightbox, and the composer
|
||||
// sessions.attachment route, the single-click ImageLightbox, and the composer
|
||||
// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
@@ -66,10 +66,10 @@ it('renders the history image pair through the authorized attachment route and o
|
||||
`)
|
||||
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
|
||||
|
||||
// Double-click opens the original-size lightbox; Escape/close dismisses it.
|
||||
// A single click opens the original-size lightbox; Escape/close dismisses it.
|
||||
const frame = userImage.closest('button')
|
||||
if (frame === null) throw new Error('image frame button missing')
|
||||
fireEvent.doubleClick(frame)
|
||||
fireEvent.click(frame)
|
||||
const lightbox = await screen.findByRole('dialog')
|
||||
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
|
||||
fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ }))
|
||||
@@ -133,4 +133,18 @@ it('accepts pasted images into the composer rail in order and removes them', asy
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
|
||||
})
|
||||
|
||||
// An unsupported file announces a transient toast (the inline strip is
|
||||
// gone) and the banner dismisses itself after its hold-and-fade lifetime.
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
const toast = await screen.findByRole('alert')
|
||||
expect(toast.textContent).toContain('Unsupported image format: text/plain')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
}, { timeout: 6_000 })
|
||||
})
|
||||
|
||||
@@ -10,14 +10,12 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_ACK_FIELD,
|
||||
WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
|
||||
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
||||
|
||||
176
apps/web/tests/plugin-config.e2e.ts
Normal file
176
apps/web/tests/plugin-config.e2e.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Web e2e scenario: the Plugins settings section — the cards a deployment's
|
||||
// exposed host-plane namespaces produce, one field edited through the real
|
||||
// wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset
|
||||
// that layering produces. Zero model calls: everything is client state plus
|
||||
// the settings document on a blank frame, so there is no fixture and a stray
|
||||
// stream would fail loud on the open llm seam.
|
||||
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 { join } from 'node:path'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plugin-config', import.meta.url))
|
||||
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: plugin configuration section', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
// Chinese browser: the section asserts the localized copy the client
|
||||
// derives from it, as the rest of the settings surface does.
|
||||
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 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
/**
|
||||
* Open the settings dialog on the Plugins section. The scenarios share one
|
||||
* page so the settings document accumulates across them, so this leaves any
|
||||
* dialog a previous scenario opened closed first — its mask would otherwise
|
||||
* swallow the trigger click.
|
||||
*/
|
||||
async function openPlugins() {
|
||||
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
}
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '插件' }).click()
|
||||
await expect
|
||||
.poll(() => dialog.getByRole('button', { name: '插件' }).getAttribute('aria-current'), { timeout: 5_000 })
|
||||
.toBe('true')
|
||||
return dialog
|
||||
}
|
||||
|
||||
/** The settings document as the Host has written it so far. */
|
||||
async function settingsDocument(): Promise<string> {
|
||||
return readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8').catch(() => '')
|
||||
}
|
||||
|
||||
it('shows one card per exposed host-plane namespace', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards'))
|
||||
const dialog = await openPlugins()
|
||||
|
||||
// Every card the shipped web composition exposes: the shell executor, the
|
||||
// agent loop, and the DeepSeek search provider.
|
||||
await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
|
||||
// Collapsed: a card's fields appear only once it is expanded.
|
||||
expect(await dialog.getByLabel('命令超时(毫秒)').count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stages an edit and writes it only when saved', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
|
||||
const dialog = await openPlugins()
|
||||
await dialog.getByText('终端', { exact: true }).click()
|
||||
|
||||
const timeout = dialog.getByLabel('命令超时(毫秒)')
|
||||
await timeout.waitFor({ timeout: 10_000 })
|
||||
// The composed default this deployment ships, before any user layer.
|
||||
expect(await timeout.inputValue()).toBe('60000')
|
||||
await timeout.fill('12000')
|
||||
await timeout.blur()
|
||||
|
||||
// Nothing crosses the wire until the user saves: leaving the control is
|
||||
// not a decision to store the value.
|
||||
expect(await settingsDocument()).not.toContain('timeoutMs')
|
||||
const save = dialog.getByRole('button', { name: '保存', exact: true })
|
||||
await expect.poll(() => save.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await save.click()
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
// Presence in the user layer is what the badge reports, and the reset is
|
||||
// offered only for a field that has one.
|
||||
await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '恢复默认' }).count()).toBe(1)
|
||||
// A settled form offers no save to repeat.
|
||||
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('drops a staged edit on discard without touching the document', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-discard'))
|
||||
const dialog = await openPlugins()
|
||||
await dialog.getByText('终端', { exact: true }).click()
|
||||
const timeout = dialog.getByLabel('命令超时(毫秒)')
|
||||
await timeout.waitFor({ timeout: 10_000 })
|
||||
|
||||
await timeout.fill('7000')
|
||||
await dialog.getByRole('button', { name: '放弃修改' }).click()
|
||||
|
||||
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('12000')
|
||||
expect(await settingsDocument()).toContain('timeoutMs: 12000')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('refuses to save a draft that is not a number', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-invalid'))
|
||||
const dialog = await openPlugins()
|
||||
await dialog.getByText('终端', { exact: true }).click()
|
||||
const timeout = dialog.getByLabel('命令超时(毫秒)')
|
||||
await timeout.waitFor({ timeout: 10_000 })
|
||||
|
||||
await timeout.fill('soon')
|
||||
|
||||
const save = dialog.getByRole('button', { name: '保存', exact: true })
|
||||
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
|
||||
expect(await dialog.getByText('请填数字;留空表示使用默认值。').count()).toBe(1)
|
||||
await dialog.getByRole('button', { name: '放弃修改' }).click()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('clears the field back to the composed default on reset', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-reset'))
|
||||
const dialog = await openPlugins()
|
||||
await dialog.getByText('终端', { exact: true }).click()
|
||||
const timeout = dialog.getByLabel('命令超时(毫秒)')
|
||||
await timeout.waitFor({ timeout: 10_000 })
|
||||
expect(await timeout.inputValue()).toBe('12000')
|
||||
|
||||
// The reset stages the composed default; the document still carries the
|
||||
// override until the save lands.
|
||||
await dialog.getByRole('button', { name: '恢复默认' }).click()
|
||||
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000')
|
||||
expect(await settingsDocument()).toContain('timeoutMs: 12000')
|
||||
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
|
||||
.toBe(false)
|
||||
expect(await timeout.inputValue()).toBe('60000')
|
||||
expect(await dialog.getByText('已覆盖').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['section.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,10 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
WELCOME_NOTICE_COPY,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
|
||||
@@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
callId: CallId('web-url-probe'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
|
||||
command: 'printf \'%s\\n\' "$DSH_WEB_URL"',
|
||||
description: 'Print current Web runtime',
|
||||
},
|
||||
agent,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toBe(`${scaffold.baseUrl}\nproduction\n`)
|
||||
.toBe(`${scaffold.baseUrl}\n`)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
|
||||
|
||||
@@ -41,9 +41,19 @@ import {
|
||||
loadOverlayPatches,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
// Client packages must not be imported here: these e2e type-check in the Host
|
||||
// aggregate, so a Client import pulls that package's whole project — and every
|
||||
// project it references — into the Host build graph. Mirrored from
|
||||
// packages/client/ui-settings-general/src/onboarding-copy.ts; a drift makes the
|
||||
// pre-acknowledgement stop suppressing the notice, which fails loudly.
|
||||
// import {
|
||||
// WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
|
||||
// } from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
||||
export const WELCOME_NOTICE_VERSION = '2026-07-30.7'
|
||||
export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const
|
||||
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
@@ -416,7 +426,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// (apps/web IS @deepseek-ai/dsh-frontend); only the URL line is silenced.
|
||||
// Preserve the composed surface-context choice because a patch replaces
|
||||
// the row's complete config.
|
||||
{ id: 'web-runtime', config: { mode: 'production', printUrl: false, surfaceContext } },
|
||||
{ id: 'web-runtime', config: { printUrl: false, surfaceContext } },
|
||||
...options.remoteAuthority === undefined
|
||||
? []
|
||||
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
|
||||
|
||||
546
apps/web/tests/schedule-after.e2e.ts
Normal file
546
apps/web/tests/schedule-after.e2e.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/** Keyless assembled-Web evidence for conversational Schedule delivery. */
|
||||
|
||||
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 { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ScheduleId,
|
||||
createEveryScheduleRecord,
|
||||
foldScheduleEvents,
|
||||
resolveEveryOccurrence,
|
||||
type EveryScheduleRecord,
|
||||
} from '@deepseek-ai/dsh-tool-schedule'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/schedule-after', import.meta.url))
|
||||
const AFTER_EXPECTED = join(SNAPSHOT_DIR, 'conversation.expected.md')
|
||||
const AT_EXPECTED = join(SNAPSHOT_DIR, 'at-conversation.expected.md')
|
||||
const EVERY_EXPECTED = join(SNAPSHOT_DIR, 'every-conversation.expected.md')
|
||||
const AFTER_PROVIDER = 'schedule-after-web-test'
|
||||
const AT_PROVIDER = 'schedule-at-web-test'
|
||||
const EVERY_PROVIDER = 'schedule-every-web-test'
|
||||
const MODEL = 'reply'
|
||||
const AFTER_PROMPT = 'Check the deployment log'
|
||||
const AFTER_REPLY = 'Reminder: Check the deployment log.'
|
||||
const AT_BROWSER_ZONE = 'Asia/Shanghai'
|
||||
const AT_USER_PROMPT = 'Remind me to review the release window in a few seconds in my local time.'
|
||||
const AT_PROMPT = 'Review the release window'
|
||||
const AT_READY = 'Ready for a browser-local reminder request.'
|
||||
const AT_ACK = 'Scheduled in your browser time zone.'
|
||||
const AT_REPLY = 'Reminder: Review the release window.'
|
||||
const EVERY_PROMPTS = ['Check primary metrics', 'Check secondary metrics'] as const
|
||||
const EVERY_REPLY = 'Reminders: Check primary metrics; Check secondary metrics.'
|
||||
const EVERY_INTERVAL_SECONDS = 60 * 60
|
||||
const EVERY_FIXTURE_AGE_MS = 90 * 60 * 1_000
|
||||
|
||||
/** Emit one complete assistant text response. */
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Deterministic model seam that turns one due reminder into ordinary assistant prose. */
|
||||
class ReminderAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield * textResponse(AFTER_REPLY)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic model seam for one multi-record fixed-rate batch. */
|
||||
class EveryReminderAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield * textResponse(EVERY_REPLY)
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalAt {
|
||||
readonly date: string
|
||||
readonly time: string
|
||||
readonly time_zone: string
|
||||
}
|
||||
|
||||
/** Render one future epoch as exact local calendar fields in an explicit zone. */
|
||||
function localAt(epoch: number, timeZone: string): LocalAt {
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(epoch).map(part => [part.type, part.value])) as Record<string, string>
|
||||
return {
|
||||
date: `${parts['year']}-${parts['month']}-${parts['day']}`,
|
||||
time: `${parts['hour']}:${parts['minute']}:${parts['second']}`,
|
||||
time_zone: timeZone,
|
||||
}
|
||||
}
|
||||
|
||||
/** Dynamic model seam proving request-local browser context becomes an explicit At selector. */
|
||||
class BrowserZoneAtAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
selectedAt: LocalAt | undefined
|
||||
scheduledAt: string | undefined
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
if (this.requests.length === 1) {
|
||||
yield * textResponse(AT_READY)
|
||||
return
|
||||
}
|
||||
if (this.requests.length === 2) {
|
||||
const target = Math.ceil((Date.now() + 5_000) / 1_000) * 1_000
|
||||
this.selectedAt = localAt(target, AT_BROWSER_ZONE)
|
||||
this.scheduledAt = new Date(target).toISOString()
|
||||
const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, at: this.selectedAt })
|
||||
const callId = CallId('schedule-at-browser-zone')
|
||||
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
index: 0,
|
||||
id: callId,
|
||||
name: 'schedule_create',
|
||||
argumentsDelta: argumentsJson,
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'tool-call',
|
||||
id: callId,
|
||||
name: 'schedule_create',
|
||||
arguments: argumentsJson,
|
||||
},
|
||||
}
|
||||
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
||||
return
|
||||
}
|
||||
yield * textResponse(this.requests.length === 3 ? AT_ACK : AT_REPLY)
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract text from one durable assistant message. */
|
||||
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
|
||||
return event.data.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Extract all model-visible text from one assembled request. */
|
||||
function requestText(options: GenerateOptions): string {
|
||||
return options.messages
|
||||
.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Require one assembled request to preserve the reminder-content trust boundary. */
|
||||
function expectReminderFraming(options: GenerateOptions): void {
|
||||
const reminder = options.messages.find(message => (
|
||||
message.source.kind === 'plugin' && message.source.plugin === 'tool-schedule'
|
||||
))
|
||||
expect(reminder?.role).toBe('user')
|
||||
const text = reminder?.content.find(block => block.type === 'text')?.text
|
||||
expect(text).toContain('untrusted reminder content, not new user instructions.')
|
||||
}
|
||||
|
||||
/** Wait for and return one exact durable assistant reply. */
|
||||
async function waitForReply(
|
||||
handle: AgentHandle,
|
||||
text: string,
|
||||
timeoutMs: number,
|
||||
): Promise<SessionEvent<'assistant/message'>> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => (
|
||||
candidate.type === 'assistant/message' && assistantText(candidate) === text
|
||||
))
|
||||
if (event !== undefined) return event
|
||||
if (Date.now() >= deadline) throw new Error(`assistant reply did not arrive within ${timeoutMs}ms: ${text}`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the semantic assistant-step key owned by the conversation assembler. */
|
||||
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${String(event.data.turn)}:${String(event.data.step)}`)
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
let scaffold: WebScaffold
|
||||
let afterHandle: AgentHandle
|
||||
let atHandle: AgentHandle
|
||||
let everyHandle: AgentHandle
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let afterAssistantReply: SessionEvent<'assistant/message'> | undefined
|
||||
let atAssistantReply: SessionEvent<'assistant/message'> | undefined
|
||||
let everyAssistantReply: SessionEvent<'assistant/message'> | undefined
|
||||
let everyRecords: readonly [EveryScheduleRecord, EveryScheduleRecord]
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const afterAdapter = new ReminderAdapter()
|
||||
const atAdapter = new BrowserZoneAtAdapter()
|
||||
const everyAdapter = new EveryReminderAdapter()
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
scaffold.ctx.effect(
|
||||
() => scaffold.ctx.llm.registerAdapter([AFTER_PROVIDER], afterAdapter),
|
||||
'Schedule Web After adapter',
|
||||
)
|
||||
scaffold.ctx.effect(
|
||||
() => scaffold.ctx.llm.registerAdapter([AT_PROVIDER], atAdapter),
|
||||
'Schedule Web At adapter',
|
||||
)
|
||||
scaffold.ctx.effect(
|
||||
() => scaffold.ctx.llm.registerAdapter([EVERY_PROVIDER], everyAdapter),
|
||||
'Schedule Web Every adapter',
|
||||
)
|
||||
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({
|
||||
viewport: { width: 1680, height: 1000 },
|
||||
locale: 'en-US',
|
||||
timezoneId: AT_BROWSER_ZONE,
|
||||
})
|
||||
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone))
|
||||
.toBe(AT_BROWSER_ZONE)
|
||||
|
||||
const cwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
const workspace = await scaffold.ctx.workspace.resolveByPath(cwd)
|
||||
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
|
||||
|
||||
afterHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('schedule-after-web-e2e'),
|
||||
meta: { cwd },
|
||||
agentOptions: { provider: AFTER_PROVIDER, model: MODEL },
|
||||
})
|
||||
afterHandle.agent.session.append('session/title', {
|
||||
title: 'Scheduled After follow-up',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
await workspace.attachSession(afterHandle.agent.id)
|
||||
const afterCreated = await scaffold.ctx.tools.execute({
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
callId: CallId('schedule-after-create'),
|
||||
name: 'schedule_create',
|
||||
arguments: { prompt: AFTER_PROMPT, after_seconds: 1 },
|
||||
agent: afterHandle.agent,
|
||||
})
|
||||
if (afterCreated.isError) {
|
||||
throw new Error(`Schedule After create failed: ${JSON.stringify(afterCreated.value)}`)
|
||||
}
|
||||
expect(afterCreated.value).toMatchObject({
|
||||
id: 'schedule-1',
|
||||
kind: 'after',
|
||||
prompt: AFTER_PROMPT,
|
||||
afterSeconds: 1,
|
||||
state: 'scheduled',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
afterAssistantReply = await waitForReply(afterHandle, AFTER_REPLY, 15_000)
|
||||
await afterHandle.agent.whenIdle()
|
||||
await expect(scaffold.ctx.sessions.flush(afterHandle.agent.session)).resolves.toBe(true)
|
||||
|
||||
everyHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('schedule-every-web-e2e'),
|
||||
meta: { cwd },
|
||||
agentOptions: { provider: EVERY_PROVIDER, model: MODEL },
|
||||
})
|
||||
everyHandle.agent.session.append('session/title', {
|
||||
title: 'Fixed-rate reminder batch',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const seededAt = Date.now()
|
||||
everyRecords = [
|
||||
createEveryScheduleRecord(
|
||||
ScheduleId('schedule-every-primary'),
|
||||
EVERY_PROMPTS[0],
|
||||
EVERY_INTERVAL_SECONDS,
|
||||
seededAt - EVERY_FIXTURE_AGE_MS,
|
||||
),
|
||||
createEveryScheduleRecord(
|
||||
ScheduleId('schedule-every-secondary'),
|
||||
EVERY_PROMPTS[1],
|
||||
EVERY_INTERVAL_SECONDS,
|
||||
seededAt - EVERY_FIXTURE_AGE_MS,
|
||||
),
|
||||
]
|
||||
for (const record of everyRecords) {
|
||||
everyHandle.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: record,
|
||||
})
|
||||
}
|
||||
await expect(scaffold.ctx.sessions.flush(everyHandle.agent.session)).resolves.toBe(true)
|
||||
await workspace.attachSession(everyHandle.agent.id)
|
||||
const everyListed = await scaffold.ctx.tools.execute({
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
callId: CallId('schedule-every-list'),
|
||||
name: 'schedule_list',
|
||||
arguments: {},
|
||||
agent: everyHandle.agent,
|
||||
})
|
||||
expect(everyListed.isError).toBe(false)
|
||||
everyAssistantReply = await waitForReply(everyHandle, EVERY_REPLY, 15_000)
|
||||
await everyHandle.agent.whenIdle()
|
||||
await expect(scaffold.ctx.sessions.flush(everyHandle.agent.session)).resolves.toBe(true)
|
||||
|
||||
atHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('schedule-at-web-e2e'),
|
||||
meta: { cwd },
|
||||
agentOptions: { provider: AT_PROVIDER, model: MODEL },
|
||||
})
|
||||
atHandle.agent.session.append('session/title', {
|
||||
title: 'Explicit local-time reminder',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
atHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Prepare the reminder test session.' }],
|
||||
source: { kind: 'plugin', plugin: 'schedule-web-e2e' },
|
||||
}))
|
||||
await atHandle.agent.whenIdle()
|
||||
expect(atAdapter.requests).toHaveLength(1)
|
||||
await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true)
|
||||
await workspace.attachSession(atHandle.agent.id)
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const workspaceItem = page.locator('[role="treeitem"]').first()
|
||||
await workspaceItem.waitFor({ timeout: 15_000 })
|
||||
const expansionDeadline = Date.now() + 5_000
|
||||
while (await workspaceItem.getAttribute('aria-expanded') !== 'true') {
|
||||
if (Date.now() >= expansionDeadline) throw new Error('workspace item did not expand')
|
||||
if (await workspaceItem.getAttribute('aria-expanded') !== 'true') {
|
||||
await workspaceItem.click()
|
||||
}
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
const atSession = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
|
||||
await atSession.waitFor({ timeout: 15_000 })
|
||||
await atSession.click()
|
||||
const composer = page.locator('textarea:enabled').last()
|
||||
await composer.fill(AT_USER_PROMPT)
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
expect(await settled).toBe(atHandle.agent.id)
|
||||
await page.getByText(AT_ACK, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
atAssistantReply = await waitForReply(atHandle, AT_REPLY, 20_000)
|
||||
await atHandle.agent.whenIdle()
|
||||
await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await atHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await everyHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await afterHandle?.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, 'Schedule Web evidence teardown failed')
|
||||
})
|
||||
|
||||
it('renders After as an ordinary assistant follow-up', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after'))
|
||||
const reminderRequest = afterAdapter.requests[0]
|
||||
if (reminderRequest === undefined) throw new Error('model did not receive the After reminder')
|
||||
expectReminderFraming(reminderRequest)
|
||||
const session = page.getByRole('treeitem', { name: /Scheduled After follow-up/ })
|
||||
await session.click()
|
||||
if (afterAssistantReply === undefined) throw new Error('After assistant reply was not captured')
|
||||
const selector = `[data-chat-anchor-key="${assistantKey(afterAssistantReply)}"]`
|
||||
const row = page.locator(selector)
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await row.textContent()).toContain(AFTER_REPLY)
|
||||
await compareOrRefreshGolden(
|
||||
AFTER_EXPECTED,
|
||||
await captureStableAria(page, selector, scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
expect(await page.locator('[data-schedule-reminder]').count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every'))
|
||||
const ids = new Set(everyRecords.map(record => record.id))
|
||||
const dispatches = everyHandle.agent.session.events.filter(event => (
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'dispatch'
|
||||
&& ids.has(event.data.id)
|
||||
))
|
||||
expect(dispatches).toHaveLength(2)
|
||||
const acceptedAt = dispatches.map((event) => {
|
||||
if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch'
|
||||
|| !('acceptedAt' in event.data)) throw new Error('expected Every dispatch')
|
||||
return event.data.acceptedAt
|
||||
})
|
||||
expect(new Set(acceptedAt).size).toBe(1)
|
||||
const decision = acceptedAt[0]
|
||||
if (decision === undefined) throw new Error('missing Every decision time')
|
||||
|
||||
const batch = everyHandle.agent.session.events.find(event => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'tool-schedule'
|
||||
&& event.data.content.some(block => block.type === 'text'
|
||||
&& block.text.startsWith('[SCHEDULE REMINDER BATCH]'))
|
||||
))
|
||||
if (batch?.type !== 'user/message') throw new Error('missing Every batch message')
|
||||
const batchBlock = batch.data.content.find(block => block.type === 'text')
|
||||
if (batchBlock?.type !== 'text') throw new Error('missing Every batch text')
|
||||
for (const record of everyRecords) {
|
||||
const occurrenceAt = resolveEveryOccurrence(record, Date.parse(decision)).occurrenceAt
|
||||
expect(batchBlock.text).toContain(JSON.stringify({
|
||||
schedule_id: record.id,
|
||||
occurrence_at: occurrenceAt,
|
||||
reminder_prompt: record.prompt,
|
||||
}).slice(1, -1))
|
||||
}
|
||||
expect(everyAdapter.requests).toHaveLength(1)
|
||||
const reminderRequest = everyAdapter.requests[0]
|
||||
if (reminderRequest === undefined) throw new Error('model did not receive the Every batch')
|
||||
expect(requestText(reminderRequest)).toContain(batchBlock.text)
|
||||
expectReminderFraming(reminderRequest)
|
||||
const active = foldScheduleEvents(everyHandle.agent.session.events).active
|
||||
expect(active).toHaveLength(2)
|
||||
expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true)
|
||||
|
||||
const session = page.getByRole('treeitem', { name: /Fixed-rate reminder batch/ })
|
||||
await session.click()
|
||||
if (everyAssistantReply === undefined) throw new Error('Every assistant reply was not captured')
|
||||
const selector = `[data-chat-anchor-key="${assistantKey(everyAssistantReply)}"]`
|
||||
const row = page.locator(selector)
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await row.textContent()).toContain(EVERY_REPLY)
|
||||
await compareOrRefreshGolden(
|
||||
EVERY_EXPECTED,
|
||||
await captureStableAria(page, selector, scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
expect(await page.locator('[data-schedule-reminder]').count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it('uses request-local browser context to create an explicit local At reminder', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at'))
|
||||
const user = atHandle.agent.session.events.find(event => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT)
|
||||
))
|
||||
if (user?.type !== 'user/message' || user.data.source.kind !== 'user') {
|
||||
throw new Error('missing browser user-rpc message')
|
||||
}
|
||||
expect(user.data.source).toMatchObject({ kind: 'user', clientTimeZone: AT_BROWSER_ZONE })
|
||||
expect(typeof (user.data.source as { rpcId?: unknown }).rpcId).toBe('string')
|
||||
|
||||
const firstRequest = atAdapter.requests[1]
|
||||
if (firstRequest === undefined) throw new Error('model did not receive the browser prompt')
|
||||
expect(requestText(firstRequest)).toContain(
|
||||
`Browser time zone for this request: ${AT_BROWSER_ZONE}. `
|
||||
+ 'Interpret otherwise-unqualified dates and times in this zone.',
|
||||
)
|
||||
expect(firstRequest.tools?.some(tool => tool.name === 'schedule_create')).toBe(true)
|
||||
const selectedAt = atAdapter.selectedAt
|
||||
const scheduledAt = atAdapter.scheduledAt
|
||||
if (selectedAt === undefined || scheduledAt === undefined) {
|
||||
throw new Error('model did not choose an explicit local At target')
|
||||
}
|
||||
expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE)
|
||||
|
||||
const toolCall = atHandle.agent.session.events.find(event => (
|
||||
event.type === 'tool/call' && event.data.name === 'schedule_create'
|
||||
))
|
||||
if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call')
|
||||
expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt })
|
||||
const created = atHandle.agent.session.events.find(event => (
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'create'
|
||||
&& event.data.schedule.kind === 'at'
|
||||
))
|
||||
if (created?.type !== 'schedule/change' || created.data.operation !== 'create') {
|
||||
throw new Error('explicit local At call did not create a durable record')
|
||||
}
|
||||
const schedule = created.data.schedule
|
||||
expect(schedule).toMatchObject({
|
||||
kind: 'at',
|
||||
prompt: AT_PROMPT,
|
||||
scheduledAt,
|
||||
})
|
||||
expect(atHandle.agent.session.events.filter(event => (
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'dispatch'
|
||||
&& event.data.id === schedule.id
|
||||
))).toHaveLength(1)
|
||||
expect(atAdapter.requests).toHaveLength(4)
|
||||
const reminderRequest = atAdapter.requests[3]
|
||||
if (reminderRequest === undefined) throw new Error('model did not receive the At reminder')
|
||||
expectReminderFraming(reminderRequest)
|
||||
|
||||
const session = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
|
||||
await session.click()
|
||||
if (atAssistantReply === undefined) throw new Error('At assistant reply was not captured')
|
||||
const selector = `[data-chat-anchor-key="${assistantKey(atAssistantReply)}"]`
|
||||
const row = page.locator(selector)
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await row.textContent()).toContain(AT_REPLY)
|
||||
await compareOrRefreshGolden(
|
||||
AT_EXPECTED,
|
||||
await captureStableAria(page, selector, scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
expect(await page.locator('[data-schedule-reminder]').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'at-conversation.expected.md',
|
||||
'conversation.expected.md',
|
||||
'every-conversation.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
|
||||
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
@@ -187,7 +187,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
|
||||
it('routes web runtime context and workspace instructions through the real CLI request', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
@@ -226,7 +226,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
@@ -261,7 +261,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
const workspaceMessage = captured.messages?.find(message =>
|
||||
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
|
||||
const systemMessage = captured.messages?.find(message => message.role === 'system')
|
||||
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
|
||||
const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
|
||||
.replace('{{webUrl}}', baseUrl)
|
||||
expect(systemMessage?.content).toContain(expectedWebSection)
|
||||
expect(workspaceMessage).toMatchInlineSnapshot(`
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -2,6 +2,6 @@ You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
|
||||
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
|
||||
You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- group "Command input": /goal
|
||||
- 'button "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"':
|
||||
- img
|
||||
- img
|
||||
- text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
|
||||
- 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]
|
||||
@@ -6,6 +6,7 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的
|
||||
- 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
34
apps/web/tests/snapshots/plugin-config/section.expected.md
Normal file
34
apps/web/tests/snapshots/plugin-config/section.expected.md
Normal file
@@ -0,0 +1,34 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "插件配置" [level=2]
|
||||
- paragraph: 配置本部署已安装的插件。
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "展开设置: 终端"':
|
||||
- text: 终端 限制 agent 运行的每一条命令。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: Agent 循环"':
|
||||
- text: Agent 循环 Agent 如何派发工具调用。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: 网页搜索"':
|
||||
- text: 网页搜索 DeepSeek 搜索提供方。
|
||||
- img
|
||||
@@ -6,6 +6,7 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- group "Command input": /goal Keep the composer context panels aligned
|
||||
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- paragraph: "Reminder: Review the release window."
|
||||
@@ -0,0 +1 @@
|
||||
- paragraph: "Reminder: Check the deployment log."
|
||||
@@ -0,0 +1 @@
|
||||
- paragraph: "Reminders: Check primary metrics; Check secondary metrics."
|
||||
@@ -10,6 +10,9 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
@@ -0,0 +1 @@
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
32
apps/web/tests/snapshots/workflow-run/ui.expected.md
Normal file
32
apps/web/tests/snapshots/workflow-run/ui.expected.md
Normal file
@@ -0,0 +1,32 @@
|
||||
- text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:":
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:"
|
||||
- button "Tool call workflow ·":
|
||||
- img
|
||||
- img
|
||||
- text: Tool call workflow ·
|
||||
- button "snapshot-flow 1 member Completed" [expanded]:
|
||||
- img
|
||||
- text: snapshot-flow 1 member Completed
|
||||
- button "Run 1 member Completed 1" [expanded]:
|
||||
- img
|
||||
- text: Run 1 member Completed 1
|
||||
- text: Reply with exactly the word WF_CHILD_OK and not… Completed
|
||||
- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The workflow returned successfully with the reply "WF_CHILD_OK". Now I need to reply with exactly "WORKFLOW_DONE" and stop.
|
||||
- paragraph: WORKFLOW_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
181
apps/web/tests/workflow-run.e2e.ts
Normal file
181
apps/web/tests/workflow-run.e2e.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// Keyless shipped-Web acceptance for the durable workflow Conversation Node.
|
||||
// Reuses the existing recorded workflow parent/child model fixtures; the real
|
||||
// workflow tool, worker, subagent provider, Session log, browser plugin graph,
|
||||
// and navigation all execute during replay.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import {
|
||||
connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot,
|
||||
} from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl')
|
||||
const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl')
|
||||
const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let prompt: string
|
||||
|
||||
const waitForParentSettlement = (): Promise<SessionId> => new Promise((resolve, reject) => {
|
||||
let dispose = (): void => {}
|
||||
dispose = scaffold.ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type !== 'turn/end' || session.header.origin === 'subagent') return
|
||||
dispose()
|
||||
void (async () => {
|
||||
await scaffold.ctx.agents.get(session.id)?.whenIdle()
|
||||
await scaffold.ctx.sessions.flush(session)
|
||||
resolve(session.id)
|
||||
})().catch(reject)
|
||||
})
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
const prompts = fixtureUserPrompts(await readFile(PARENT_FIXTURE, 'utf8'))
|
||||
expect(prompts).toHaveLength(1)
|
||||
prompt = prompts[0]!
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: PARENT_FIXTURE,
|
||||
replayChildFixtures: [CHILD_FIXTURE],
|
||||
paceMs: 25,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live'))
|
||||
const settled = waitForParentSettlement()
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill(prompt)
|
||||
await input.press('Enter')
|
||||
|
||||
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
|
||||
await workflow.waitFor({ timeout: 30_000 })
|
||||
expect(await workflow.getAttribute('aria-expanded')).toBe('true')
|
||||
const phase = page.getByRole('button', { name: /^Run/ })
|
||||
await phase.waitFor({ timeout: 15_000 })
|
||||
await phase.click()
|
||||
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
|
||||
await member.waitFor({ timeout: 15_000 })
|
||||
await member.focus()
|
||||
|
||||
const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color)
|
||||
await page.setViewportSize({ width: 560, height: 800 })
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
const darkNarrow = await page.locator('[data-workflow-run]').evaluate((element) => {
|
||||
const panel = element as HTMLElement
|
||||
panel.style.width = '356px'
|
||||
const label = element.querySelector('[data-member-label]')
|
||||
const labelWrap = element.querySelector('[data-member-label-wrap]')
|
||||
const status = element.querySelector('[data-member-status-text]')
|
||||
const disclosures = element.querySelectorAll('[data-disclosure-row]')
|
||||
const runHeader = disclosures[0]
|
||||
const phaseHeader = disclosures[1]
|
||||
const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null
|
||||
const phaseStatus = element.querySelector('[data-phase-status-text]')
|
||||
const originalPhaseTitle = phaseTitle?.textContent ?? ''
|
||||
if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout'
|
||||
const phaseTitleRight = phaseTitle?.getBoundingClientRect().right ?? 0
|
||||
const phaseStatusLeft = phaseStatus?.getBoundingClientRect().left ?? 0
|
||||
if (phaseTitle !== null) phaseTitle.textContent = originalPhaseTitle
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
color: label === null ? '' : getComputedStyle(label).color,
|
||||
decoration: label === null ? '' : getComputedStyle(label).textDecorationLine,
|
||||
focusWidth: labelWrap === null ? '' : getComputedStyle(labelWrap).outlineWidth,
|
||||
statusWidth: status?.getBoundingClientRect().width ?? 0,
|
||||
statusFontSize: status === null ? '' : getComputedStyle(status).fontSize,
|
||||
runHeight: runHeader?.getBoundingClientRect().height ?? 0,
|
||||
phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0,
|
||||
phaseTitleRight,
|
||||
phaseStatusLeft,
|
||||
}
|
||||
})
|
||||
expect(darkNarrow.clientWidth).toBe(356)
|
||||
expect(darkNarrow.scrollWidth).toBeLessThanOrEqual(darkNarrow.clientWidth)
|
||||
expect(darkNarrow.color).not.toBe(lightColor)
|
||||
expect(darkNarrow.decoration).toContain('underline')
|
||||
expect(Number.parseFloat(darkNarrow.focusWidth)).toBeGreaterThanOrEqual(2)
|
||||
expect(darkNarrow.statusWidth).toBe(64)
|
||||
expect(darkNarrow.statusFontSize).toBe('13px')
|
||||
expect(darkNarrow.runHeight).toBe(32)
|
||||
expect(darkNarrow.phaseHeight).toBe(32)
|
||||
expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft)
|
||||
await page.locator('[data-workflow-run]').evaluate((element) => {
|
||||
(element as HTMLElement).style.removeProperty('width')
|
||||
document.body.removeAttribute('data-ds-dark-theme')
|
||||
})
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
|
||||
await member.click()
|
||||
await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
|
||||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||||
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
|
||||
await settled
|
||||
|
||||
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
|
||||
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
|
||||
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
|
||||
await terminalWorkflow.waitFor()
|
||||
if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click()
|
||||
const terminalPhase = page.getByRole('button', { name: /^Run/ })
|
||||
await terminalPhase.waitFor()
|
||||
if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click()
|
||||
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
|
||||
await expect.poll(
|
||||
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
}, 90_000)
|
||||
|
||||
it('rebuilds the terminal record from history after reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history'))
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
|
||||
await workflow.waitFor({ timeout: 15_000 })
|
||||
expect(await workflow.getAttribute('aria-expanded')).toBe('false')
|
||||
await workflow.click()
|
||||
const phase = page.getByRole('button', { name: /^Run/ })
|
||||
await phase.waitFor()
|
||||
await phase.click()
|
||||
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
|
||||
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('stays clean and owns only its one golden', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,7 @@
|
||||
"tests/trajectory-virtualization.e2e.ts",
|
||||
"tests/lifecycle-chrome.e2e.ts",
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/plugin-config.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/default-model.e2e.ts",
|
||||
@@ -66,11 +67,13 @@
|
||||
"tests/agent-preset-selection.e2e.ts",
|
||||
"tests/agent-preset-authoring.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/schedule-after.e2e.ts",
|
||||
"tests/feedback-command.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/goal-command-presentation.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/subagent-interrupt.e2e.ts",
|
||||
"tests/subagent-interrupt-ui.e2e.ts",
|
||||
@@ -86,7 +89,8 @@
|
||||
"tests/chat-continuous-conversation.e2e.ts",
|
||||
"tests/composer-tab-geometry.e2e.ts",
|
||||
"tests/complex-history.perf.ts",
|
||||
"tests/pwsh-terminal.e2e.ts"
|
||||
"tests/pwsh-terminal.e2e.ts",
|
||||
"tests/workflow-run.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
|
||||
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
|
||||
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
|
||||
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
|
||||
+ 'For client-plugin HMR, run `pnpm dsh web --dev` together with `pnpm run dev:web`.'
|
||||
+ 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
|
||||
|
||||
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
|
||||
function rejectStandaloneServe(): Plugin {
|
||||
@@ -143,6 +143,7 @@ export default defineConfig({
|
||||
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-attachment$/, replacement: src('../../packages/client/ui-attachment/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user