Merge remote-tracking branch 'origin/master' into worktree/web-remote-markdown-images

# Conflicts:
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/README.md
#	packages/client/ui-primitives/README.zh.md
#	packages/client/ui-primitives/src/markdown/MarkdownText.tsx
#	packages/client/ui-primitives/tests/markdown.spec.tsx
This commit is contained in:
Yichen Jiang
2026-08-03 16:09:51 +08:00
2018 changed files with 163222 additions and 14125 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215
README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c
README.md: 76d9ed65398322cb9244a31661ee59b60c23f793
README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_tui_timer
plugin_tui_hmr["hmr<br/>@cordisjs/plugin-hmr"]
cfg --> plugin_tui_hmr
plugin_tui_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"]
cfg --> plugin_tui_repository_plugins
plugin_tui_llm["llm<br/>@deepseek-ai/dsh-llm"]
cfg --> plugin_tui_llm
plugin_tui_session["session<br/>@deepseek-ai/dsh-session"]
@@ -28,16 +30,30 @@ flowchart LR
cfg --> plugin_tui_tasks
plugin_tui_llm_retry["llm-retry<br/>@deepseek-ai/dsh-llm-retry"]
cfg --> plugin_tui_llm_retry
plugin_tui_settings["settings<br/>@deepseek-ai/dsh-settings-local"]
cfg --> plugin_tui_settings
plugin_tui_credentials["credentials<br/>@deepseek-ai/dsh-credentials-local"]
cfg --> plugin_tui_credentials
plugin_tui_llm_pi_ai["llm-pi-ai<br/>@deepseek-ai/dsh-llm-pi-ai"]
cfg --> plugin_tui_llm_pi_ai
plugin_tui_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
cfg --> plugin_tui_session_persistence_jsonl
plugin_tui_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_tui_session_query_sqlite
plugin_tui_telemetry_otel["telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
cfg --> plugin_tui_telemetry_otel
plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
cfg --> plugin_tui_subprocess
plugin_tui_bash_local["bash-local<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_tui_bash_local
plugin_tui_sandbox["sandbox<br/>@deepseek-ai/dsh-sandbox-local"]
cfg --> plugin_tui_sandbox
plugin_tui_sandbox_policy["sandbox-policy<br/>@deepseek-ai/dsh-sandbox-policy"]
cfg --> plugin_tui_sandbox_policy
plugin_tui_bash_sandbox["bash-sandbox<br/>@deepseek-ai/dsh-bash-sandbox"]
cfg --> plugin_tui_bash_sandbox
plugin_tui_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
cfg --> plugin_tui_approval
plugin_tui_permission["permission<br/>@deepseek-ai/dsh-permission"]
cfg --> plugin_tui_permission
plugin_tui_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
cfg --> plugin_tui_tool_bash
plugin_tui_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"]
@@ -70,16 +86,24 @@ flowchart LR
cfg --> plugin_tui_token_meter
plugin_tui_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
cfg --> plugin_tui_compact_basic
plugin_tui_command_compact["command-compact<br/>@deepseek-ai/dsh-command-compact"]
cfg --> plugin_tui_command_compact
plugin_tui_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
cfg --> plugin_tui_subagent
plugin_tui_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
cfg --> plugin_tui_subagent_spawn
plugin_tui_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
cfg --> plugin_tui_subagent_fork
plugin_tui_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
cfg --> plugin_tui_tool_subagent_control
plugin_tui_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
cfg --> plugin_tui_tool_subagent_list_agents
plugin_tui_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent
plugin_tui_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent_fork
plugin_tui_tool_subagent_report["tool-subagent-report<br/>@deepseek-ai/dsh-tool-subagent-report"]
cfg --> plugin_tui_tool_subagent_report
plugin_tui_workflow_workerthread["workflow-workerthread<br/>@deepseek-ai/dsh-workflow-workerthread"]
cfg --> plugin_tui_workflow_workerthread
plugin_tui_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
@@ -90,14 +114,34 @@ flowchart LR
cfg --> plugin_tui_spill_local
plugin_tui_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
cfg --> plugin_tui_spill_policy
plugin_tui_session_checkpoint_policy["session-checkpoint-policy<br/>@deepseek-ai/dsh-session-checkpoint-policy"]
cfg --> plugin_tui_session_checkpoint_policy
plugin_tui_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"]
cfg --> plugin_tui_tool_result_prune
plugin_tui_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_tui_tool_todo
plugin_tui_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
cfg --> plugin_tui_tool_goal
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
cfg --> plugin_tui_tool_ralph
plugin_tui_tool_str_replace_editor["tool-str-replace-editor<br/>@deepseek-ai/dsh-tool-str-replace-editor"]
cfg --> plugin_tui_tool_str_replace_editor
plugin_tui_repeat_tool_guard["repeat-tool-guard<br/>@deepseek-ai/dsh-repeat-tool-guard"]
cfg --> plugin_tui_repeat_tool_guard
plugin_tui_web["web<br/>@deepseek-ai/dsh-web"]
cfg --> plugin_tui_web
plugin_tui_web_search_deepseek["web-search-deepseek<br/>@deepseek-ai/dsh-web-search-deepseek"]
cfg --> plugin_tui_web_search_deepseek
plugin_tui_tool_web["tool-web<br/>@deepseek-ai/dsh-tool-web"]
cfg --> plugin_tui_tool_web
plugin_tui_tools["tools<br/>@deepseek-ai/dsh-tools"]
cfg --> plugin_tui_tools
plugin_tui_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
cfg --> plugin_tui_system_prompt
plugin_tui_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
cfg --> plugin_tui_agent_loop
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
cfg --> plugin_tui_fs_local
plugin_tui_fs_sandbox["fs-sandbox<br/>@deepseek-ai/dsh-fs-sandbox"]
cfg --> plugin_tui_fs_sandbox
plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_tui_llm_deepseek
```
@@ -106,6 +150,7 @@ flowchart LR
| --- | --- |
| `timer` | `@cordisjs/plugin-timer` |
| `hmr` | `@cordisjs/plugin-hmr` |
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
| `llm` | `@deepseek-ai/dsh-llm` |
| `session` | `@deepseek-ai/dsh-session` |
| `session-title` | `@deepseek-ai/dsh-session-title` |
@@ -114,11 +159,18 @@ flowchart LR
| `agent` | `@deepseek-ai/dsh-agent` |
| `tasks` | `@deepseek-ai/dsh-tasks-local` |
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
| `settings` | `@deepseek-ai/dsh-settings-local` |
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash-local` | `@deepseek-ai/dsh-bash-local` |
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
| `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
@@ -135,20 +187,34 @@ flowchart LR
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
| `command-compact` | `@deepseek-ai/dsh-command-compact` |
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` |
| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` |
| `web` | `@deepseek-ai/dsh-web` |
| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
| `tools` | `@deepseek-ai/dsh-tools` |
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml).

View File

@@ -22,6 +22,13 @@
config:
root: ['.']
# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub
# repository Plugin generations. The app registers the DSH-owned runtime even
# when the list is empty so a later personal-config edit can load
# transactionally; one-shot headless runs consume the startup value only.
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
- id: llm
name: '@deepseek-ai/dsh-llm'
@@ -56,38 +63,117 @@
- id: llm-retry
name: '@deepseek-ai/dsh-llm-retry'
# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a
# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries
# below without a restart, and is what the web Models page writes.
- id: settings
name: '@deepseek-ai/dsh-settings-local'
# Credential store: the live process environment over `$DSH_HOME/.env`
# (owner-only file, hot-reloaded). Adapters resolve their key references
# through it at each request, so no key is inlined in this file. The web
# Models page's key inputs write it through `credentials.set`; nothing hoists
# the document into the process environment, which would make every stored key
# read as an unrotatable ambient override.
- id: credentials
name: '@deepseek-ai/dsh-credentials-local'
# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra
# models in the picker) until a `llm-pi-ai:` settings section supplies provider
# profiles — then those routes register live, keys resolving per request
# through their apiKeyEnv references, and drop again when the section empties.
# Supplying those profiles is exactly what the web Models page does. Which
# adapters exist is composition; which providers run is the user's settings
# document.
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
- provider: openai
apiKey: !!js process.env.OPENAI_API_KEY
baseURL: !!js process.env.OPENAI_BASE_URL
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
baseURL: !!js process.env.ANTHROPIC_BASE_URL
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js >-
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
root: !!js dshHomePath('sessions')
# TUI consumes this shared session capability. Its launcher supplies a unique
# process-local path; non-TUI surfaces disable the row in their overlay.
# process-local path; other surfaces repoint or disable the row in their
# overlay (web patches it to an ephemeral in-memory index).
- id: session-query-sqlite
name: '@deepseek-ai/dsh-session-query-sqlite'
config:
path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db'
# Session telemetry, on for every dsh surface: mirrors every session-log
# event (assistant/chunk projected to first-of-step) plus ops markers onto
# OTLP/HTTP log records, streaming on the batch processor's cadence
# (10s/batch here) — not at exit; a crash loses at most the last unexported
# interval. No telemetry/record redaction rule is mounted yet, so exports
# are the raw captured copy; the deployment stance, env seams, and
# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
# random UUID; delete the file to reset the identity) as the Resource's
# user.id. The exporter/processor values bound the shutdown drain to ~1s
# against an unreachable collector: exporter.timeoutMillis is both the
# per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
# is the processor's own cap on that one export cycle — the second bound
# when the exporter's clock alone does not fire. Every surface's exit path
# drains it: web/headless dispose on SIGINT/SIGTERM, and the TUI's normal
# exit and /resume handoff both dispose the root.
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
compression: gzip
timeoutMillis: 1000
processor:
scheduledDelayMillis: 10000
maxQueueSize: 2048
maxExportBatchSize: 2048
exportTimeoutMillis: 1500
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
# Every shipped product surface starts with the same file-effect boundary.
# The environment remains an explicit deployment override; otherwise fresh
# sessions pin workspace-write + ask through the permission service below.
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
config:
timeoutMs: 60000
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
@@ -102,6 +188,8 @@
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
config:
sampleOverCapGlobResults: false
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
@@ -151,6 +239,11 @@
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
# Human `/compact`: one useful reduction below the automatic threshold. Backend
# independent, so it follows whichever compaction service this leaf mounts.
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
@@ -164,17 +257,31 @@
config:
providerName: fork
# Continuable background children are selected per delegation tool. The
# separately loaded follow-up tool registers the one global `send_message`.
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'
- id: tool-subagent-list-agents
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
backgroundMode: continuable
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
backgroundMode: continuable
# Optional direct-child return channel; absent from roots and one-shot agents.
- id: tool-subagent-report
name: '@deepseek-ai/dsh-tool-subagent-report'
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
@@ -195,6 +302,71 @@
config:
maxInlineBytes: 50000
# Durability checkpoints before each model request and top-level dispatch.
- id: session-checkpoint-policy
name: '@deepseek-ai/dsh-session-checkpoint-policy'
# Compacts oversized tool results before the broader conversation compactor
# runs, preserving the model-visible result within the configured budget.
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
config:
thresholdChars: 8192
headChars: 4096
tailChars: 1024
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Persisted same-session goals reach the model and the slash menu here; the
# domain, driver, and `/goal` command are above.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# Fresh-agent Ralph iteration over a build-time-fixed script.
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
subagentProvider: spawn
maxRounds: 64
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# Consecutive-repeat reminders on the tool chain.
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8]
argumentsPreviewChars: 500
# Every surface enables the stable web_search model surface. DeepSeek search
# resolves the same DEEPSEEK_API_KEY credential the Models page manages for
# chat, at each search; its Messages endpoint is separate from the
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
# disabled and no fetch provider is mounted: that provider defers SSRF
# protection and the model would choose the request target. Search is a full
# auxiliary model request with server-side retrieval, so this shipped DeepSeek
# route gets 60s while the provider-neutral tool default remains 30s.
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-official
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
searchTimeoutMs: 60000
# ── rows every surface mounts, whose values each overlay states ──────────────
# The tool registry. Presentation mode is a surface choice, so each overlay
@@ -216,15 +388,14 @@
config:
agents: []
# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the
# TUI states it explicitly because that value is also the session workspace.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; the TUI
# states it explicitly because that value is also the session workspace.
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
# The native DeepSeek adapter; reads the key/base-url the boot's layered .env
# loading left in the environment. Thinking defaults are a surface choice.
# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per
# request from the `llm-deepseek:` settings section over this entry, with the
# key coming from the credential store below. Thinking defaults are a surface
# choice.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL

View File

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

View File

@@ -22,7 +22,7 @@
config:
agents:
- id: main
provider: deepseek
provider: deepseek-official
model: deepseek-v4-pro
cwd: !!js process.cwd()
@@ -36,8 +36,8 @@
Verify your work by running the code or tests. Keep answers brief and
factual.
# Shipped default: full thinking at max effort on every request (wire-only
# defaults; they never enter the request header).
# Shipped default: full thinking at max effort on every request. Exact-model
# resolution materializes request defaults before the request header is logged.
- id: llm-deepseek
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
@@ -46,7 +46,7 @@
reasoningEffort: max
# This single-session app resolves relative paths from the process cwd.
- id: fs-local
- id: fs-sandbox
config:
cwd: !!js process.cwd()
@@ -71,29 +71,17 @@
- id: agent-loop-invariant
name: '@deepseek-ai/dsh-agent-loop/invariant'
- id: session-checkpoint-policy
name: '@deepseek-ai/dsh-session-checkpoint-policy'
# The derived query index behind `/resume`. The launcher provides a unique
# process-local path because this SQLite backend has one writer owner; the
# project-local fallback applies when no launcher sets the typed slot.
- id: session-reference
name: '@deepseek-ai/dsh-session-reference'
# Compacts oversized tool results before the broader conversation compactor
# runs, preserving the model-visible result within the configured budget.
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
# Persisted same-session goals reach the model and the slash menu here; the
# domain, driver, and `/goal` command are in the base.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# A separate fixed consumer demonstrates fresh-agent Ralph iteration without
# changing the workflow tool or same-session goal behavior.
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
# Terminal-multiplexer context, mounted only where a terminal exists.
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 900000
# The keyboard-backed provider behind ask_user_question and the plan-mode
# review, and the front door it renders inside.

View File

@@ -10,13 +10,23 @@
# ── surface-specific values the base deliberately omits ─────────────────────
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.
- id: hmr
disabled: true
# Session query is a TUI capability; Web owns its own session presentation.
# Web content search runs on an ephemeral in-memory index. The service
# activates at boot, while first-search defers the node:sqlite import and
# in-memory handle so Node 22 startup stays quiet until content search
# actually uses SQLite. That search then reconciles this boot's sources.
- id: session-query-sqlite
disabled: true
config:
path: ':memory:'
openAt: first-search
- id: tools
config:
@@ -31,50 +41,6 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# The web surface replaces the unrestricted local executors with the shared
# sandbox policy. Its default preserves the previous unrestricted behavior;
# DSH_PERMISSION_MODE and the browser permission picker can confine a session.
- insert:
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
- id: bash-local
disabled: true
- id: fs-local
disabled: true
# ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into
@@ -92,7 +58,7 @@
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: './.storages'
root: !!js dshHomePath('storages')
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
@@ -108,19 +74,19 @@
writeEveryEvents: 200
writeIntervalMs: 5000
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Resolve bind host, SSH launch, and display once at boot, then mount the
# matching dual-face directory picker. Mount -native or -browse directly in
# an overlay to pin the interaction.
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-auto'
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-browse'
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
provider: deepseek
provider: deepseek-official
model: deepseek-v4-flash
# ── layer 2: transport/service ──────────────────────────────────────────────

View File

@@ -9,6 +9,7 @@
},
"files": [
"lib/bin.js",
"assets",
"config",
"src"
],
@@ -47,10 +48,12 @@
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
@@ -58,29 +61,38 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
@@ -95,31 +107,43 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tmux-context": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@earendil-works/pi-tui": "0.80.7",
"commander": "^15.0.0",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@types/js-yaml": "^4.0.9",
"execa": "^10.0.0",
"node-pty": "1.1.0"
}
}

View File

@@ -1,10 +1,12 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* for the Web/headless surface.
* Everything here is what must exist before the Loader runs: layered env,
* the patch composition over the shipped base and surface overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and surface overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
* settles. The environment is what the bin already loaded (ambient plus the
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
* provider and is never hoisted here.
*/
import { readFileSync } from 'node:fs'
@@ -14,8 +16,13 @@ import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import {
boot,
installFailLoud,
loadOverlayPatches,
loadPersonalPatches,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -23,6 +30,9 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
@@ -58,6 +68,38 @@ export function resolveLanTrust(
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/**
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
* off-by-mistake over on-by-mistake. Throws when the switch is set but the
* row is absent — a silently no-op "disabled" privacy switch would keep
* exporting while the user believes it is off.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
* @returns the disable patch, or `undefined` when telemetry stays enabled.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '') return undefined
if (!hasRow) {
throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
}
return { id: TELEMETRY_ROW_ID, disabled: true }
}
/**
* Whether a config file carries the telemetry row, parsed under the same
* `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
* that compose their patch lists outside {@link AppCLIEntry} (the TUI).
* @param file - absolute path of the config or overlay file.
* @returns true when a top-level (or inserted) row has the telemetry id.
*/
export function configHasTelemetryRow(file: string): boolean {
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -104,8 +146,10 @@ export interface AppCLIEntryOptions {
* `$DSH_HOME/config.yaml` overlay is applied instead.
*/
extraOverlayPath?: string
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
dev: boolean
/** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */
watchPersonalConfig: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
@@ -119,6 +163,8 @@ export interface AppCLIEntryOptions {
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context) => Promise<void> | void
}
/**
@@ -144,12 +190,11 @@ export class AppCLIEntry {
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: layered env → patch composition → Loader include
* boot (dev row before await) → fail-loud triple.
* Run the boot chain: patch composition → Loader installation → surface
* preparation → config-tree boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.loadEnvLayers()
this.composePatches()
await this.bootTree()
this.assertBoot()
@@ -159,11 +204,6 @@ export class AppCLIEntry {
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh', resolveDshHome())
}
/**
* Compose the patch set from profile json, CLI flags, and the resolved
* frontend dist. Patches replace a row's config wholesale, so each patched row's yml
@@ -203,29 +243,50 @@ export class AppCLIEntry {
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const generated = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
this.patches = generated
// Telemetry opt-out: a row can only be turned off at the patch layer
// (config cannot disable an entry), and the switch must hold BEFORE the
// plugin constructs — its exporter.url validation is load-time fail-loud.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
/** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
private async bootTree(): Promise<void> {
// One include of the shared base with every overlay as a sibling patch
// list: patches never cross an include boundary, so nesting them would
// silently stop reaching base rows. The surface overlay applies first, then
// this entry's profile-json and CLI-flag patches, which therefore win.
const patches = [
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
...loadOverlayPatches('dsh', this.options.overlayPath),
...this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
...overlay,
...this.patches,
]
// An explicit --config overlay REPLACES the personal overlay, so there is
// then no personal layer to keep live — the watcher is personal-only.
const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined
const patches = compose(
this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
)
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
await this.options.prepare?.(ctx)
// Config-only HMR for the personal overlay: module reload stays off for
// this surface (web.cordis.yml disables the shared `hmr` row until its
// reload lifecycle is tested), so this row watches no module roots.
if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
if (watchPersonal) {
await watchPersonalPatches(this.ctx, { binName: 'dsh', compose })
}
}
/** Install the diagnostic for plugin rejections that happen after settled boot. */
@@ -288,7 +349,7 @@ export class AppCLIEntry {
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first')
}
}
}

View File

@@ -2,8 +2,11 @@
* Commander adapter for the `dsh` command-line entry: the one place argv is
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
* and dynamic-imports that mode's module. One program: the default (no
* subcommand) is the TUI/headless surface with option-only flags; `meta` and
* `web` are real subcommands. Commander owns `--help`/`--version` and parse
* subcommand) is the TUI/headless surface with option-only flags;
* `meta`, `upgrade`, and `web` are real subcommands; the experimental ones
* (`meta`, `upgrade`) run only under the `--experimental` flag or
* `DSH_EXPERIMENTAL=1`. Commander owns
* `--help`/`--version` and parse
* errors — it prints and exits at the point of failure (a domain failure routes through
* `command.error`), so this returns only a resolved mode.
* @module @deepseek-ai/dsh/args
@@ -23,22 +26,39 @@ interface TuiInvocation {
resume?: string
}
/**
* Print the composed config tree and exit, without booting: `--dump-config`
* composes the shipped base, the surface overlay, and the `--config` or
* personal overlay — exactly the layers that surface would boot;
* `--dump-default-config` stops at the surface overlay (the shipped tree, no
* user layer).
*/
interface DumpConfigInvocation {
mode: 'dump-config'
surface: 'tui' | 'web'
/** Omit the `--config`/personal layer and print only the shipped composition. */
defaultOnly: boolean
/** The `--config` overlay to compose instead of the personal one. */
config?: string
}
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode: 'headless'
prompt: string
}
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
interface MetaInvocation {
mode: 'meta'
}
/**
* Guided fresh-session entry: `dsh upgrade` seeds the first turn with the
* `dsh-upgrade` skill. It always mints a
* fresh session in the invoking directory and takes no options — `--resume`,
* `--config`, and `-p` are rejected as mistyped, so there is nothing to carry.
* Guided fresh-session entry: `dsh upgrade` seeds the first turn
* with the `dsh-upgrade` skill. It always mints a
* fresh session in the invoking directory and takes no options beyond the
* experimental gate — `--resume`, `--config`, and `-p` are rejected as
* mistyped, so there is nothing to carry.
*/
interface SkillSessionInvocation {
mode: 'upgrade'
@@ -69,6 +89,7 @@ interface WebInvocation {
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation =
| TuiInvocation
| DumpConfigInvocation
| HeadlessInvocation
| MetaInvocation
| SkillSessionInvocation
@@ -82,6 +103,34 @@ interface WebOptions {
dev?: boolean
workspaceRoot?: string
trustedHost?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}
/**
* Resolve the two dump flags for one surface, or return `undefined` when
* neither was passed. Both flags together are contradictory (one includes the
* user layer, the other excludes it) and fail loud through `error`.
*/
function resolveDump(
surface: 'tui' | 'web',
options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
error: (message: string) => never,
): DumpConfigInvocation | undefined {
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
error('error: --dump-config and --dump-default-config are mutually exclusive')
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && options.config !== undefined) {
error('error: --dump-default-config prints the shipped tree and takes no --config')
}
return {
mode: 'dump-config',
surface,
defaultOnly,
...options.config !== undefined && { config: options.config },
}
}
/**
@@ -108,9 +157,11 @@ function resolveWeb(options: WebOptions): WebInvocation {
* TUI/headless surface; `web` is a subcommand.
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
* @param version - the version string `--version` prints; read from this app's package.json.
* @param experimentalEnv - whether the environment opts into experimental
* subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary.
* @returns the resolved invocation (only reached on a valid, non-help invocation).
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation {
let resolved: DshInvocation | undefined
const program = new Command()
.name('dsh')
@@ -135,7 +186,26 @@ Examples:
.option('--resume <id>', 'continue a past session by id')
.option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
.option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
.action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => {
.option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit')
.action((options: {
config?: string
configReplace?: string
prompt?: string
resume?: string
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}) => {
const dump = resolveDump('tui', options, message => program.error(message))
if (dump !== undefined) {
// The dump prints composition; a boot-only flag alongside it would be
// silently ignored, so reject the mix loud.
if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) {
program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace')
}
resolved = dump
return
}
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
// run, and --config/--resume are TUI inputs that must not silently
@@ -168,22 +238,42 @@ Examples:
// a leaked config/prompt/resume option is a mistyped invocation that must fail
// loud rather than silently run and drop the input.
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>()
const parent = program.opts<{
config?: string
configReplace?: string
prompt?: string
resume?: string
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}>()
if (parent.config !== undefined || parent.configReplace !== undefined
|| parent.prompt !== undefined || parent.resume !== undefined) {
program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`)
|| parent.prompt !== undefined || parent.resume !== undefined
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`)
}
}
// `meta` and `upgrade` are experimental: each runs only under its own
// `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in,
// and fails loud otherwise so the gate is never silently skipped.
const requireExperimental = (command: string, flag: boolean | undefined): void => {
if (flag !== true && !experimentalEnv) {
program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
}
}
// Registration order is the rendered help order, so daily use comes first
// and the harness-development surfaces (`web --dev`, `meta`) come last.
// `upgrade` is a guided fresh-session entry: it takes no options and always
// mints a fresh session, so nothing is left to carry.
// and the harness-development surfaces (`web --dev`, `meta`)
// come last. `upgrade` is a guided fresh-session entry: beyond the
// experimental gate it takes no options and always mints a fresh session,
// so nothing is left to carry.
program
.command('upgrade')
.description('update this dsh installation to the latest version')
.action(() => {
.description('update this dsh installation to the latest version (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
rejectParentOptions('upgrade')
requireExperimental('upgrade', options.experimental)
resolved = { mode: 'upgrade' }
})
@@ -195,19 +285,28 @@ Examples:
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
.action((options: WebOptions) => {
rejectParentOptions('web')
const dump = resolveDump('web', options, message => program.error(message))
if (dump !== undefined) {
resolved = dump
return
}
resolved = resolveWeb(options)
})
program
.command('meta')
.description('work on the dsh source that runs this command, from any directory')
.action(() => {
.description('work on the dsh source that runs this command, from any directory (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
rejectParentOptions('meta')
requireExperimental('meta', options.experimental)
resolved = { mode: 'meta' }
})

View File

@@ -25,7 +25,8 @@ function readVersion(): string {
}
loadEnv('dsh')
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
// The env opt-in is read at the process boundary; `1` is the documented value.
const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1')
switch (invocation.mode) {
case 'web': {
@@ -43,14 +44,19 @@ switch (invocation.mode) {
await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace)
break
}
case 'dump-config': {
const { runDumpConfig } = await import('./dump-config.ts')
runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config)
break
}
case 'meta': {
const { runMeta } = await import('./tui.ts')
await runMeta()
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
await runTui(undefined, undefined, SOURCE_ROOT)
break
}
case 'upgrade': {
const { runSkillSession } = await import('./tui.ts')
await runSkillSession(`dsh-${invocation.mode}`)
const { runTui } = await import('./tui.ts')
await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
break
}
default:

View File

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

View File

@@ -78,10 +78,22 @@ export async function runHeadless(task: string): Promise<void> {
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
dev: false,
watchPersonalConfig: false,
port: 0,
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// Signal exits must still dispose the tree: the composition mounts
// exit-drained plugins (telemetry's queued tail and shutdown marker would
// otherwise be lost), and Node's default signal exit skips disposal.
let signalled = false
const disposeAndExit = (code: number): void => {
if (signalled) return
signalled = true
void dispose().finally(() => { process.exit(code) })
}
process.on('SIGTERM', () => { disposeAndExit(143) })
process.on('SIGINT', () => { disposeAndExit(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))

View File

@@ -0,0 +1,111 @@
/**
* Static terminal rasters derived from the official 24x24 DeepSeek icon.
*
* Source: `../../assets/deepseek-color.svg`, whose path data is copied exactly
* from the supplied official icon (viewBox `0 0 24 24`, fill `#4D6BFE`). Each
* tier rasterizes that path into a square binary
* mask without redrawing its contour. The Unicode form packs two source rows
* into `▀`/`▄`/`█`; the ASCII fallback packs the same two bits into
* `'`/`_`/`#`. Assets contain no ANSI and are never generated at runtime.
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-art
*/
/** Responsive official-icon raster tier. */
export type TuiFirstRunWelcomeArtTier = 'full' | 'compact' | 'minimal'
/** One raster with a block-cell primary and bit-equivalent ASCII fallback. */
export interface TuiFirstRunWelcomeArt {
/** Two vertical source pixels per terminal cell. */
readonly unicode: readonly string[]
/** Same two-bit cells encoded as top `'`, bottom `_`, and both `#`. */
readonly ascii: readonly string[]
}
const fullUnicode = Object.freeze([
' ▄',
' ▄▄▄▄▄▄▄▄▄▄███▀ ██▄',
' ▄███████████████▄ ████▄ ▄▄▄▄██',
' ▄███████████████████▄ ████████████▀',
' ▄██████████████████████▄ ▀█████████▀',
'▄███▀█████████████████████▄ ████▀▀',
'███ ▀▀█████████▀▀▀█████████▀',
'███ ▀███████▀█ ▀███████',
'███▄ ▀███████▄ ▀█████▀',
'▀███ ▀██████████████',
' ▀███▄ ▀███████████▀',
' ▀███▄ ▄▄▄ ▀████████▀',
' █████▄ ███▄▄ ▀█████▄▄',
' ▀█████████████▄▄▄▄█▀█████▀',
' ▀▀███████████▀▀',
])
const fullAscii = Object.freeze([
' _',
" __________###' ##_",
' _###############_ ####_ ____##',
" _###################_ ############'",
" _######################_ '#########'",
"_###'#####################_ ####''",
"### ''#########'''#########'",
"### '#######'# '#######",
"###_ '#######_ '#####'",
"'### '##############",
" '###_ '###########'",
" '###_ ___ '########'",
" #####_ ###__ '#####__",
" '#############____#'#####'",
" ''###########''",
])
const compactUnicode = Object.freeze([
' ▄▄▄▄▄▄▄██▀ █▄ ▄',
' ▄███████████▄▄ ███▄▄████',
' ████████████████▄ ▀██████▀',
'██▀▀▀▀▀████████████▄▄██▀',
'██ ▀█████▄ ▀█████',
'██▄ ▀████▄ ▄████',
' ██▄ ████████▀',
' ██▄ ▄▄ ▀█████▀',
' ▀███▄▄▄███▄ ████▄▄',
' ▀▀▀███████▀▀',
])
const compactAscii = Object.freeze([
" _______##' #_ _",
' _###########__ ###__####',
" ################_ '######'",
"##'''''############__##'",
"## '#####_ '#####",
"##_ '####_ _####",
" ##_ ########'",
" ##_ __ '#####'",
" '###___###_ ####__",
" '''#######''",
])
const minimalUnicode = Object.freeze([
' ▄▄▄▄▄▄ ▄▄',
' ▄████████▄ ▀████▀',
'█▀▀▀▀███████▄██▀',
'█▄ ▀███ ▀███',
'▀█▄ ▀█████',
' ▀█▄▄ █▄▄▀███▄',
' ▀▀▀▀▀▀',
])
const minimalAscii = Object.freeze([
' ______ __',
" _########_ '####'",
"#''''#######_##'",
"#_ '### '###",
"'#_ '#####",
" '#__ #__'###_",
" ''''''",
])
/** Exact-path terminal rasters by responsive tier. */
export const TUI_FIRST_RUN_WELCOME_WHALE = Object.freeze({
full: Object.freeze({ unicode: fullUnicode, ascii: fullAscii }),
compact: Object.freeze({ unicode: compactUnicode, ascii: compactAscii }),
minimal: Object.freeze({ unicode: minimalUnicode, ascii: minimalAscii }),
}) satisfies Readonly<Record<TuiFirstRunWelcomeArtTier, TuiFirstRunWelcomeArt>>

View File

@@ -0,0 +1,49 @@
/**
* Centrally owned version and all-locale Chinese copy for the shipped TUI first-run notice.
*
* A material wording change increments {@link TUI_FIRST_RUN_WELCOME_NOTICE_VERSION}
* so every Harness home presents the revised notice once.
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-copy
*/
/** Copy version persisted after the user explicitly continues. */
export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 4
/** Locale-shaped text rendered by the first-run welcome overlay. */
export interface TuiFirstRunWelcomeNoticeCopy {
/** Overlay heading. */
readonly title: string
/** Ordered prose paragraphs. */
readonly paragraphs: readonly string[]
/** Enter action label. */
readonly continueLabel: string
/** Hint shown when the prose is scrollable. */
readonly scrollHint: string
/** Status shown while the acknowledgement reaches disk. */
readonly saving: string
/** Retry message shown when the acknowledgement cannot be persisted. */
readonly saveError: string
}
/** Complete Chinese notice used for every locale. */
const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze<TuiFirstRunWelcomeNoticeCopy>({
title: 'DeepSeek Harness',
paragraphs: Object.freeze([
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外如果您有任何反馈与建议请在企业微信群中留言告诉我们。每一条反馈都会帮助我们把它打磨得更好。',
]),
continueLabel: '继续',
scrollHint: '↑/↓ 滚动',
saving: '正在保存确认…',
saveError: '无法保存确认,请按 Enter 重试。',
})
/** Locale map whose entries deliberately share the single Chinese owner copy. */
export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({
'zh-CN': TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
en: TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
})
/** Locale presented by the shipped first-run notice. */
export const TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE = 'zh-CN' as const

View File

@@ -0,0 +1,385 @@
/**
* Effect-owned first-run overlay for the shipped `dsh` TUI.
*
* The launcher owns the per-DSH_HOME acknowledgement boundary; the component
* reaches the terminal only through the mounted `ctx.tui` overlay service and
* never touches the session or model context.
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome
*/
import { randomUUID } from 'node:crypto'
import { lstat, mkdir, open, rename, rm } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import type { Context } from 'cordis'
import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from '@earendil-works/pi-tui'
import {
disposeRootAndExit,
type TuiComponent,
type TuiFocusable,
type TuiOverlayHost,
} from '@deepseek-ai/dsh-tui'
import {
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
type TuiFirstRunWelcomeNoticeCopy,
} from './tui-first-run-welcome-copy.ts'
import {
TUI_FIRST_RUN_WELCOME_WHALE,
type TuiFirstRunWelcomeArtTier,
} from './tui-first-run-welcome-art.ts'
// TODO: Move acknowledgement persistence behind @deepseek-ai/dsh-storage once
// its backend contract supports concurrent host processes. This same-value
// marker must not inherit JSON lost updates or SQLite busy failures.
const ACKNOWLEDGEMENT_DIRECTORY = 'notices'
const ACKNOWLEDGEMENT_BASENAME = 'tui-first-run-welcome'
/** Cordis plugin name. */
export const name = 'tui-first-run-welcome'
/** The notice can open only after the terminal-local overlay service mounts. */
export const inject = ['tui']
/** Launcher-resolved configuration for the terminal-local notice. */
interface Config {
/** Absolute DeepSeek Harness home owning this acknowledgement. */
readonly dshHome: string
/** Render the bit-equivalent printable ASCII icon fallback. */
readonly asciiArt?: boolean
}
/**
* Detect an explicitly non-Unicode terminal locale for the static ASCII art fallback.
* @param env - Process environment carrying locale and terminal declarations.
* @returns `true` only when the environment explicitly declares an ASCII-only locale or dumb terminal.
*/
export function needsTuiFirstRunWelcomeAsciiArt(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG
return env.TERM === 'dumb' || locale === 'C' || locale === 'POSIX'
}
/**
* Resolve the immutable marker for one notice version.
* @param dshHome - Resolved Harness home.
* @param version - Copy version whose acknowledgement is queried.
* @returns Absolute marker path beneath the Harness home.
*/
export function tuiFirstRunWelcomeAcknowledgementPath(dshHome: string, version: number): string {
return join(
dshHome,
ACKNOWLEDGEMENT_DIRECTORY,
`${ACKNOWLEDGEMENT_BASENAME}-v${String(version)}.ack`,
)
}
/**
* Test whether one notice version has been acknowledged.
* @param dshHome - Resolved Harness home.
* @param version - Copy version to inspect.
* @returns `true` only for a regular marker file; a malformed marker fails loud.
*/
export async function hasTuiFirstRunWelcomeAcknowledgement(
dshHome: string,
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
): Promise<boolean> {
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
try {
const info = await lstat(path)
if (!info.isFile()) throw new Error(`TUI welcome acknowledgement is not a file: ${path}`)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return false
throw error
}
}
/**
* Persist one version acknowledgement by syncing a random same-directory file
* before atomically replacing the immutable marker. Concurrent launches publish
* the same fact, so same-value last-writer-wins replacement loses no state.
* @param dshHome - Resolved Harness home.
* @param version - Copy version being acknowledged.
*/
export async function acknowledgeTuiFirstRunWelcome(
dshHome: string,
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
): Promise<void> {
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
const directory = dirname(path)
const temp = join(directory, `.${basename(path)}.${randomUUID()}.tmp`)
await mkdir(directory, { recursive: true, mode: 0o700 })
await syncDirectory(dirname(directory))
let handle: Awaited<ReturnType<typeof open>> | undefined
try {
handle = await open(temp, 'wx', 0o600)
await handle.sync()
const created = handle
handle = undefined
await created.close()
await rename(temp, path)
} catch (error) {
/* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */
try {
await handle?.close()
} finally {
await rm(temp, { force: true })
}
throw error
/* v8 ignore stop */
}
try {
await syncDirectory(directory)
/* v8 ignore next -- rename is the commit point; directory-fsync fault injection is platform-specific */
} catch {
// Swallow post-rename directory fsync failure: the marker is already committed,
// and crash loss can only make the notice reappear on the safe side.
}
}
/** Sync one POSIX directory after publishing a child entry. */
/* v8 ignore start -- Windows rejects directory opens; POSIX unit coverage owns this path. */
async function syncDirectory(path: string): Promise<void> {
if (process.platform === 'win32') return
const handle = await open(path, 'r')
try {
await handle.sync()
} finally {
await handle.close()
}
}
/* v8 ignore stop */
/** Render one visible-width-padded line inside the notice frame. */
function framed(content: string, innerWidth: number, host: TuiOverlayHost): string {
const clipped = truncateToWidth(content, innerWidth, '')
return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}`
}
/** Center one line by terminal column width. */
function centered(content: string, width: number): string {
const clipped = truncateToWidth(content, width, '')
const remaining = Math.max(0, width - visibleWidth(clipped))
return `${' '.repeat(Math.floor(remaining / 2))}${clipped}`
}
/**
* Select the art tier for the actual overlay width and viewport height.
* @param innerWidth - Columns inside the frame.
* @param viewportRows - Current terminal rows.
* @returns full, compact, minimal, or no art when prose must take priority.
*/
export function tuiFirstRunWelcomeArtTier(
innerWidth: number,
viewportRows: number,
): TuiFirstRunWelcomeArtTier | undefined {
const compositionCapacity = Math.max(1, Math.max(7, Math.floor(viewportRows * 0.9)) - 5)
if (innerWidth >= 96 && TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length <= compositionCapacity) return 'full'
if (innerWidth >= 80 && TUI_FIRST_RUN_WELCOME_WHALE.compact.unicode.length + 4 <= compositionCapacity) return 'compact'
if (innerWidth >= 64 && TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode.length + 4 <= compositionCapacity) return 'minimal'
return undefined
}
/** Wrap the centrally owned prose while promoting its opening quotation. */
function proseLines(
copy: TuiFirstRunWelcomeNoticeCopy,
width: number,
host: TuiOverlayHost,
): string[] {
const lines: string[] = []
for (const [index, paragraph] of copy.paragraphs.entries()) {
if (index > 0) lines.push('')
const quoteEnd = paragraph.startsWith('“') ? paragraph.indexOf('”') : -1
if (quoteEnd > 0) {
const quote = paragraph.slice(0, quoteEnd + 1)
const remainder = paragraph.slice(quoteEnd + 1).trimStart()
lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width))
lines.push('')
if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width))
} else {
lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width))
}
}
return lines
}
/** Render centered static brand art without putting ANSI into its owner file. */
function artLines(
tier: TuiFirstRunWelcomeArtTier,
width: number,
host: TuiOverlayHost,
asciiArt: boolean,
): string[] {
const art = TUI_FIRST_RUN_WELCOME_WHALE[tier][asciiArt ? 'ascii' : 'unicode']
return art.map(line => centered(host.theme.brand(line), width))
}
/** Responsive, scrollable notice whose only completion input is Enter. */
export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable {
focused = false
private scrollOffset = 0
private bodyCapacity = 1
private maxScrollOffset = 0
private saving = false
private saveFailed = false
constructor(
private readonly host: TuiOverlayHost,
private readonly copy: TuiFirstRunWelcomeNoticeCopy,
private readonly acknowledge: () => Promise<void>,
private readonly exit: () => void,
private readonly asciiArt = false,
) {}
invalidate(): void {}
render(width: number): string[] {
const frameWidth = Math.max(6, width)
const innerWidth = Math.max(1, frameWidth - 4)
const viewportRows = this.host.viewport.rows
const tier = tuiFirstRunWelcomeArtTier(innerWidth, viewportRows)
const availableRows = Math.max(7, Math.floor(viewportRows * 0.9))
const title = this.host.theme.bold(this.host.theme.brand(this.copy.title))
let fixedHeader: string[] = []
let fullContentHeader: string[] = []
let body: string[]
let fullArt: string[] | undefined
const fullArtWidth = 44
if (tier === 'full') {
fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt)
const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3)
fullContentHeader = [centered(title, contentWidth), '']
body = proseLines(this.copy, contentWidth, this.host)
} else {
const art = tier === undefined ? [] : artLines(tier, innerWidth, this.host, this.asciiArt)
fixedHeader = [...art, ...art.length === 0 ? [] : [''], centered(title, innerWidth), '']
body = proseLines(this.copy, innerWidth, this.host)
}
const compositionCapacity = Math.max(1, availableRows - 5)
const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length - fullContentHeader.length)
this.bodyCapacity = Math.min(body.length, bodyLimit)
const maxOffset = Math.max(0, body.length - this.bodyCapacity)
this.maxScrollOffset = maxOffset
this.scrollOffset = Math.min(this.scrollOffset, maxOffset)
const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyCapacity)
const top = this.host.theme.dim(`${'─'.repeat(Math.max(0, frameWidth - 2))}`)
const separator = this.host.theme.dim(`${'─'.repeat(Math.max(0, frameWidth - 2))}`)
const bottom = this.host.theme.dim(`${'─'.repeat(Math.max(0, frameWidth - 2))}`)
const action = this.host.theme.bold(this.host.theme.accent(`Enter ${this.copy.continueLabel}`))
const hasAbove = this.scrollOffset > 0
const hasBelow = this.scrollOffset < maxOffset
const scroll = hasAbove || hasBelow
? `${hasAbove ? '↑' : ' '} ${this.copy.scrollHint} ${hasBelow ? '↓' : ' '}`
: ''
const status = this.saveFailed
? this.host.theme.error(this.copy.saveError)
: this.saving
? this.host.theme.dim(this.copy.saving)
: this.host.theme.dim(scroll)
const fullContent = [...fullContentHeader, ...visibleBody]
const composition = fullArt === undefined
? [...fixedHeader, ...visibleBody]
: Array.from({ length: Math.max(fullArt.length, fullContent.length) }, (_, index) => {
const art = fullArt[index] ?? ''
const line = fullContent[index] ?? ''
const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}`
return `${left} ${line}`
})
return [
top,
...composition.map(line => framed(line, innerWidth, this.host)),
separator,
framed(centered(action, innerWidth), innerWidth, this.host),
framed(centered(status, innerWidth), innerWidth, this.host),
bottom,
]
}
handleInput(data: string): void {
if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) {
this.exit()
return
}
if (matchesKey(data, Key.enter)) {
if (!this.saving) void this.commit()
return
}
if (this.saving || matchesKey(data, Key.escape)) return
if (matchesKey(data, Key.up)) this.scrollBy(-1)
else if (matchesKey(data, Key.down)) this.scrollBy(1)
else if (matchesKey(data, Key.pageUp)) this.scrollBy(-this.bodyCapacity)
else if (matchesKey(data, Key.pageDown)) this.scrollBy(this.bodyCapacity)
else if (matchesKey(data, Key.home)) this.scrollTo(0)
else if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollOffset)
}
private scrollBy(delta: number): void {
this.scrollTo(this.scrollOffset + delta)
}
private scrollTo(offset: number): void {
this.scrollOffset = Math.min(this.maxScrollOffset, Math.max(0, offset))
this.host.invalidate()
}
private async commit(): Promise<void> {
this.saving = true
this.saveFailed = false
this.host.invalidate()
try {
await this.acknowledge()
this.host.close()
} catch {
this.saving = false
this.saveFailed = true
this.host.invalidate()
}
}
}
/**
* Open the first-run notice through the mounted TUI's FIFO overlay owner.
* @param ctx - Plugin context carrying the terminal-local TUI service.
* @param config - Launcher-resolved Harness home.
*/
export function apply(ctx: Context, config: Config): void {
const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
const pending = new Set<Promise<void>>()
const acknowledge = (): Promise<void> => {
const task = acknowledgeTuiFirstRunWelcome(config.dshHome)
pending.add(task)
const settled = (): void => { pending.delete(task) }
void task.then(settled, settled)
return task
}
ctx.effect(() => async () => {
await Promise.allSettled(pending)
}, 'tui first-run welcome acknowledgement')
ctx.tui.openOverlay({
create: host => new TuiFirstRunWelcomeComponent(
host,
copy,
acknowledge,
() => { disposeRootAndExit(ctx, 0) },
config.asciiArt ?? false,
),
options: {
width: '100%',
maxHeight: '90%',
anchor: 'center',
margin: 0,
},
})
}

View File

@@ -8,12 +8,11 @@
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory.
* `dsh meta`
* ({@link runMeta}) is the one exception — it makes this harness checkout the
* workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh
* session whose first turn auto-invokes a bundled skill. After boot, the
* agent's system prompt is told the path to this harness checkout so it can
* find its own source.
* `dsh meta` is the one exception — it makes this harness
* checkout the workspace. `dsh upgrade` is a fresh session whose
* first turn auto-invokes a bundled skill. After boot, the agent's system
* prompt is told the path to this harness checkout so it can find its own
* source.
* @module @deepseek-ai/dsh/tui
*/
@@ -26,13 +25,15 @@ import {
addHarnessSourceSection,
boot,
installFailLoud,
loadEnv,
loadOverlayPatches,
loadPersonalPatches,
resolveConfigPath,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { SessionId } from '@deepseek-ai/dsh-session'
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import type { Context } from 'cordis'
@@ -43,6 +44,16 @@ import {
type MainSessionIdentity,
type TuiResumeHost,
} from '@deepseek-ai/dsh-tui'
import {
apply as applyTuiFirstRunWelcome,
hasTuiFirstRunWelcomeAcknowledgement,
inject as tuiFirstRunWelcomeInject,
name as tuiFirstRunWelcomeName,
needsTuiFirstRunWelcomeAsciiArt,
} from './tui-onboarding/tui-first-run-welcome.ts'
import {
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
} from './tui-onboarding/tui-first-run-welcome-copy.ts'
const NAME = 'dsh'
@@ -62,30 +73,11 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives.
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** The harness checkout used as the `dsh meta` workspace and source prompt path. */
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the CLI PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI with this harness checkout as the workspace
* (`dsh meta`), whatever directory it was launched from.
*/
export async function runMeta(): Promise<void> {
return runTui(undefined, undefined, SOURCE_ROOT)
}
/**
* Run the interactive TUI as a guided fresh session whose first turn invokes a
* bundled skill (`dsh upgrade` → `dsh-upgrade`).
* Always mints a fresh session in the invoking directory; the skill is seeded
* only on this first launch, so a later `--resume` of the session is an ordinary
* TUI session with no re-injection.
* @param skill - the bundled skill name to auto-invoke as the first turn.
*/
export async function runSkillSession(skill: string): Promise<void> {
return runTui(undefined, undefined, undefined, skill)
}
/**
* Run the interactive TUI from the invoking directory.
* @param config - an overlay patch list applied over the shared base and the
@@ -100,8 +92,8 @@ export async function runSkillSession(skill: string): Promise<void> {
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
* first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
* on a resume, so it never re-fires; reaches the app through
* first turn, or `undefined`. Set only by `dsh upgrade` and
* ignored on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
@@ -123,16 +115,21 @@ export async function runTui(
)
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
// Both .env layers are loaded, so switching the workspace here cannot alter
// environment precedence. The cwd IS the workspace seam: the shipped config
// The bin already loaded the invoking directory's .env, and that is the
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
// and hoisting it would make every stored key read as a read-only ambient
// override on the next run — unrotatable from the TUI or the web page.
// The environment is settled, so switching the workspace here cannot alter
// its precedence. The cwd IS the workspace seam: the shipped config
// resolves the session cwd and the HMR watch root from it, so one chdir moves
// both together. Sessions themselves live under the Harness home so `/resume`
// spans every workspace, and are unaffected by this chdir.
if (workspace !== undefined) process.chdir(workspace)
const dshHome = resolveDshHome()
const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement(
dshHome,
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
)
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
// flag, so the resumed process rehydrates through this same intake. The
@@ -144,8 +141,22 @@ export async function runTui(
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
// Resume always enters the default surface because meta rejects parent
// options, including `--resume`. The resumed session already persists its cwd.
// The Loader mounts entries concurrently, so `ui-tui` can already hold the
// terminal (raw mode, bracketed paste, keyboard protocol) when something
// else fails. A config-tree failure settles through `boot`, which disposes
// the tree itself; this release covers the rejections `boot` cannot see — a
// plugin's detached async work rejecting while mounting is still in flight
// or after the tree settled. Disposing the tree runs the TUI's own shutdown,
// which stops the terminal and hands the shell back; without it such a
// failure returns to a corrupted prompt. `app.current` is captured from
// boot's `prepare` hook, so it holds the root context for the whole mounting
// window rather than only after boot resolves.
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})
// Resume always enters the default surface because meta rejects
// parent options, including `--resume`. The resumed session already persists
// its cwd.
const resumeArgs = (sessionId: string): string[] => [
`--resume=${sessionId}`,
// Both config flags must survive the handoff: resuming into a different
@@ -197,18 +208,33 @@ export async function runTui(
// demo or test config would silently run on the user's provider and model.
// `--config-replace` additionally discards the base and the surface overlay.
const replaceTree = configReplace !== undefined
const patches = replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
// Same opt-out semantics as the web surface (resolveTelemetryPatch: any
// non-empty value disables; setting the switch against a tree without the
// row fails loud rather than silently no-opping a privacy switch). The row
// presence is checked against the tree actually booting, so a
// --config-replace tree is judged on its own rows, not the shipped base's.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [
...replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? personalPatches
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
],
...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const patches = composePatches(loadPersonalPatches(NAME) ?? [])
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined),
bootConfig,
patches,
(hostCtx) => {
// Runs after the Loader installs and before any config-tree entry mounts,
// so the fail-loud release hook can reach the tree for the whole window in
// which an entry may reject.
app.current = hostCtx
// The launcher owns session identity and the exit line: a config-mounted
// app bundle reads both from these slots, so no cordis.yml key can drop
// resume.
@@ -239,7 +265,25 @@ export async function runTui(
}
},
)
// The shipped tree includes HMR and keeps personal config live. An explicit
// --config tree replaces the personal overlay (so there is nothing to keep
// live), and a --config-replace or HMR-less tree remains a valid composition
// that still receives the startup overlay but deliberately has no hidden
// watcher.
if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) {
await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches })
}
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
if (showFirstRunWelcome) {
await ctx.plugin({
name: tuiFirstRunWelcomeName,
inject: tuiFirstRunWelcomeInject,
apply: applyTuiFirstRunWelcome,
}, {
dshHome,
asciiArt: needsTuiFirstRunWelcomeAsciiArt(),
})
}
}
/* v8 ignore stop */

View File

@@ -7,23 +7,87 @@
*/
import { fileURLToPath } from 'node:url'
import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type { Context } from 'cordis'
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import { AppCLIEntry } from './app-cli-entry.ts'
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const DSH_WEB_URL = 'DSH_WEB_URL' as const
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
type WebMode = 'production' | 'development'
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
const updateContract = mode === 'development'
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
+ 'The browser provides no implicit DOM, route, or screenshot context. '
+ updateContract
+ 'Starting another server does not update this GUI. '
+ 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. '
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.'
}
/** Resolve the canonical loopback URL from the active Web server. */
function localWebUrl(ctx: Context): string {
const port = ctx.get('httpServer')?.port
if (port === undefined) throw new Error('dsh web: httpServer service missing while resolving Web runtime')
return `http://${LOOPBACK_HOST}:${String(port)}`
}
/**
* Register the launcher-owned prompt and shell runtime context before the
* shared config tree mounts. The earlier injections install the prompt
* sections and managed Bash contributor when their owning services activate;
* dynamic values read the bound server only when consumed.
* @param ctx - Web root context with Loader installed but no config tree mounted.
* @param sourceRoot - absolute checkout root resolved from the launcher module.
* @param mode - whether this process mounted the client-plugin HMR receiver.
*/
export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, sourceRoot)
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), mode),
})
})
ctx.inject(['bashEnv'], (runtimeCtx) => {
runtimeCtx.bashEnv.register({
name: 'web-runtime',
variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
},
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: mode }),
})
})
}
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the shipped Web overlay value stands.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
* @param config - an overlay of loader patches applied over the shipped web
@@ -38,17 +102,21 @@ export async function runWeb(
trustedHosts: string[] | undefined,
config?: string,
): Promise<void> {
const mode: WebMode = dev ? 'development' : 'production'
const entry = new AppCLIEntry({
configPath: BASE_CONFIG,
overlayPath: WEB_OVERLAY,
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
dev,
prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) },
watchPersonalConfig: true,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
let exiting = false
const shutdown = (code: number): void => {
@@ -57,12 +125,13 @@ export async function runWeb(
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
// Install shutdown handling before publishing readiness: supervisors may
// send a signal as soon as they observe the URL line.
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
console.log(`dsh web: ${resolvedLocalWebUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
}

View File

@@ -1,18 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
const parse = (argv: string[], experimentalEnv = false) => parseDshArgs(argv, '1.2.3', experimentalEnv)
/**
* `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets
* Commander print to the real streams; capture the exit code and mute output.
*/
function exitCode(argv: string[]): number {
function exitCode(argv: string[], experimentalEnv = false): number {
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
parse(argv)
parse(argv, experimentalEnv)
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
} catch {
return exit.mock.calls.at(-1)?.[0] as number
@@ -24,13 +24,15 @@ function exitCode(argv: string[]): number {
afterEach(() => { vi.restoreAllMocks() })
describe('parseDshArgs', () => {
it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => {
it('routes each mode by its shape: default TUI, -p headless, experimental and web subcommands', () => {
expect(parse([])).toEqual({ mode: 'tui' })
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' })
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
expect(parse(['meta'])).toEqual({ mode: 'meta' })
// Experimental subcommands run under the per-invocation flag or the env opt-in.
expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' })
expect(parse(['meta'], true)).toEqual({ mode: 'meta' })
// Bare `web` carries no host/port: the shipped Web overlay owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
@@ -39,12 +41,36 @@ describe('parseDshArgs', () => {
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
// Guided fresh-session entries carry nothing: bare mode discriminant only.
expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' })
expect(parse(['upgrade', '--experimental'])).toEqual({ mode: 'upgrade' })
expect(parse(['upgrade'], true)).toEqual({ mode: 'upgrade' })
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
})
it('routes the dump flags per surface: composed with the user layer, or shipped only', () => {
expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false })
expect(parse(['--dump-config', '--config', 'c.yml']))
.toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' })
expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true })
expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false })
expect(parse(['web', '--dump-config', '--config', 'w.yml']))
.toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' })
expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true })
// The two dump flags contradict each other; boot-only flags alongside a
// dump would be silently ignored; the shipped tree takes no user overlay.
expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1)
expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1)
expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
// A leaked dump flag on a subcommand that has none is a mistyped invocation.
expect(exitCode(['meta', '--experimental', '--dump-config'])).toBe(1)
expect(exitCode(['upgrade', '--experimental', '--dump-config'])).toBe(1)
})
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
// Empty resume/prompt would be swallowed downstream; --prompt mixed with
// TUI inputs must not lose them. (Bad host/port are gated by the webserver
@@ -65,17 +91,32 @@ describe('parseDshArgs', () => {
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
// Same rule for each subcommand that shares no option with the default
// surface, so a leaked flag is a typo, not something to ignore.
// `meta` fixes its own config tree and always starts fresh, so every
// default-surface option is rejected.
expect(exitCode(['meta', '--resume', 's'])).toBe(1)
expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['meta', '-p', 'task'])).toBe(1)
// `upgrade` takes no options: any leaked default-surface flag is a
// mistyped invocation, not a silently-dropped input.
expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1)
// `meta` fixes its own config tree and always starts fresh,
// so every default-surface option is rejected.
expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1)
// `upgrade` takes no options beyond the gate: any leaked default-surface
// flag is a mistyped invocation, not a silently-dropped input.
expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1)
// The pre-release command names have no compatibility aliases.
expect(exitCode(['experimental-meta'])).toBe(1)
expect(exitCode(['experimental-upgrade'])).toBe(1)
})
it('gates experimental subcommands behind --experimental or the env opt-in', () => {
// Bare `meta`/`upgrade` without either opt-in must fail loud, not run.
expect(exitCode(['meta'])).toBe(1)
expect(exitCode(['upgrade'])).toBe(1)
// A leaked default-surface flag stays a typo even when the gate is passed
// by the environment alone.
expect(exitCode(['meta', '--resume', 's'], true)).toBe(1)
// The flag and the env opt-in may coexist.
expect(parse(['meta', '--experimental'], true)).toEqual({ mode: 'meta' })
expect(parse(['upgrade', '--experimental'], true)).toEqual({ mode: 'upgrade' })
})
it('exits 0 for --help (disclosing web) and --version', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a
# dict keyed by provider name, and a list is the shape users reach for. The
# entry rejects while `ui-tui` — mounted concurrently by the Loader — already
# holds the terminal, which is the boot failure the fail-loud release hook
# exists for.
- id: llm-pi-ai
config:
providers:
- provider: openai
apiKey: keyless-invalid-shape

View File

@@ -0,0 +1,155 @@
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
const fixtures: string[] = []
const PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
script, cwd, env_json, actions_json = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(env_json))
actions = json.loads(actions_json)
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe("sh", ["sh", script], env)
output = bytearray()
action_index = 0
deadline = time.monotonic() + 15
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
os.write(fd, actions[action_index]["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if action_index != len(actions):
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
sys.exit(124)
sys.exit(os.waitstatus_to_exitcode(status))
`
interface Action {
readonly waitFor: string
readonly send: string
}
interface Fixture {
readonly binDirectory: string
readonly launchLog: string
readonly pnpmLog: string
readonly root: string
readonly script: string
}
afterEach(async () => {
await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
})
function executable(path: string, content: string): void {
writeFileSync(path, content)
chmodSync(path, 0o755)
}
async function createFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
fixtures.push(root)
const checkoutDirectory = join(root, 'checkout')
const scriptsDirectory = join(checkoutDirectory, 'scripts')
const sourceBinDirectory = join(checkoutDirectory, 'bin')
const fakeBinDirectory = join(root, 'fake-bin')
const binDirectory = join(root, 'path-bin')
for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
mkdirSync(directory, { recursive: true })
}
const script = join(scriptsDirectory, 'install.sh')
copyFileSync(installer, script)
const launchLog = join(root, 'launch.log')
const pnpmLog = join(root, 'pnpm.log')
executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
`)
await execa('git', ['init', '-q'], { cwd: checkoutDirectory })
await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory })
await execa('git', [
'-c', 'user.name=dsh-test',
'-c', 'user.email=dsh-test@example.invalid',
'commit', '-qm', 'fixture',
], { cwd: checkoutDirectory })
writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
return { binDirectory, launchLog, pnpmLog, root, script }
}
async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
const result = await execa('python3', [
'-c',
PTY_DRIVER,
fixture.script,
fixture.root,
JSON.stringify({
DSH_BIN_DIR: fixture.binDirectory,
DSH_HOME: join(fixture.root, 'home/.dsh'),
DSH_TEST_LAUNCH_LOG: fixture.launchLog,
DSH_TEST_PNPM_LOG: fixture.pnpmLog,
HOME: join(fixture.root, 'home'),
PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
}),
JSON.stringify(actions),
], { reject: false, stripFinalNewline: false, timeout: 20_000 })
expect(result.exitCode, result.stderr).toBe(0)
return result.stdout
}
describe.runIf(process.platform !== 'win32')('one-line installer interface choice', { timeout: 25_000 }, () => {
it('builds and launches the Web UI when the default choice is accepted', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: '\n' },
])
expect(output).toContain('launching Web UI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
})
it('rejects an unknown choice, then launches the TUI without building', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: 'terminal\n' },
{ waitFor: 'choose 1 for Web UI or 2 for TUI', send: '2\n' },
])
expect(output).toContain('launching TUI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('\n')
})
})

View File

@@ -0,0 +1,112 @@
/**
* Node 22 startup-output smoke for the shipped Web CLI composition.
*
* Only the dedicated Node compatibility gate opts this test in after building
* both artifacts; ordinary Vitest inventory deterministically skips it.
* The child runs built artifacts under plain Node with the real shipped
* config (base.cordis.yml + the web.cordis.yml overlay).
* Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the
* shipped quiescent disposer.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import yaml from 'js-yaml'
import { describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
const webDist = join(repoRoot, 'apps/web/dist/index.html')
// The web overlay owns the session-query-sqlite lazy-open patch row.
const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml')
const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
interface ConfigRow {
id?: string
config?: { openAt?: unknown }
}
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
construct: value => String(value),
})
const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolveRun, rejectRun) => {
const env: NodeJS.ProcessEnv = {
...process.env,
DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
DSH_HOME: join(cwd, '.dsh'),
}
delete env.DEEPSEEK_BASE_URL
delete env.NODE_OPTIONS
delete env.NODE_NO_WARNINGS
const child = spawn(process.execPath, [
builtBin,
'web',
'--host',
'127.0.0.1',
'--port',
'0',
], {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout += chunk
if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
settled = true
child.kill('SIGTERM')
}
})
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
child.kill('SIGKILL')
rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
}, 60_000)
child.on('error', (error) => {
clearTimeout(timer)
rejectRun(error)
})
child.on('close', (code) => {
clearTimeout(timer)
if (!settled) {
rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
return
}
resolveRun({ stdout, stderr, code: code ?? -1 })
})
})
}
describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
it('boots and disposes the shipped composition without a SQLite startup warning', async () => {
expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[]
const searchRow = rows.find(row => row.id === 'session-query-sqlite')
expect(searchRow?.config?.openAt).toBe('first-search')
const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
try {
const result = await runBuiltWeb(cwd)
expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
expect(result.code).toBe(0)
expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
} finally {
await rm(cwd, { recursive: true, force: true })
}
}, 70_000)
})

View File

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

View File

@@ -6,11 +6,11 @@ import { execa } from 'execa'
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const POSIX_PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
import errno, fcntl, json, os, pty, select, signal, struct, sys, termios, time
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds, columns, rows = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(launch_env_json))
env.update({"COLUMNS": "100", "LINES": "30"})
env.update({"COLUMNS": columns, "LINES": rows})
# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
# banner to the per-letter gradient (one SGR per letter), breaking literal
# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
@@ -20,6 +20,7 @@ pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(columns), 0, 0))
output = bytearray()
action_index = 0
@@ -36,9 +37,14 @@ while time.monotonic() < deadline:
chunk = b""
if chunk:
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
while action_index < len(actions):
marker = actions[action_index]["waitFor"].encode()
if output.count(marker) < actions[action_index].get("occurrence", 1):
break
action = actions[action_index]
if "writeFile" in action:
if "signal" in action:
os.kill(pid, getattr(signal, action["signal"]))
elif "writeFile" in action:
target = os.path.join(cwd, action["writeFile"]["path"])
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w", encoding="utf-8") as handle:
@@ -68,9 +74,15 @@ if actual_exit != int(expected_exit):
/** One terminal input or workspace mutation performed after its marker renders. */
type TuiPtyAction =
| { readonly waitFor: string; readonly send: string }
| {
readonly waitFor: string
readonly occurrence?: number
readonly send: string
}
| { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM' }
| {
readonly waitFor: string
readonly occurrence?: number
readonly writeFile: { readonly path: string; readonly content: string }
readonly send?: string
}
@@ -89,6 +101,12 @@ export interface TuiPtySmokeOptions {
readonly env?: Readonly<NodeJS.ProcessEnv>
readonly expectedExitCode?: number
readonly timeoutMs?: number
/** Existing isolated workspace to reuse; when omitted the harness creates and removes one. */
readonly cwd?: string
/** Pseudo-terminal columns; defaults to 100. */
readonly columns?: number
/** Pseudo-terminal rows; defaults to 30. */
readonly rows?: number
/** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
readonly prepare?: (cwd: string) => Promise<void>
/** Inspect the workspace after a passing run, before the temp dir is removed. */
@@ -119,6 +137,8 @@ async function runPosixPtySmoke(
JSON.stringify(options.actions ?? []),
String(options.expectedExitCode ?? 0),
String(timeoutMs / 1_000),
String(options.columns ?? 100),
String(options.rows ?? 30),
], {
stdin: 'ignore',
timeout: timeoutMs + 5_000,
@@ -150,8 +170,8 @@ async function runWindowsPtySmoke(
let timedOut = false
const terminal = pty.spawn(launch.command, launch.args, {
name: 'xterm-256color',
cols: 100,
rows: 30,
cols: options.columns ?? 100,
rows: options.rows ?? 30,
cwd,
env: definedEnv({
...process.env,
@@ -159,8 +179,8 @@ async function runWindowsPtySmoke(
// Match the POSIX driver: no COLORTERM, so the banner never takes the
// truecolor gradient path under a developer's shell.
COLORTERM: undefined,
COLUMNS: '100',
LINES: '30',
COLUMNS: String(options.columns ?? 100),
LINES: String(options.rows ?? 30),
}),
})
const timer = setTimeout(() => {
@@ -169,9 +189,14 @@ async function runWindowsPtySmoke(
}, timeoutMs)
terminal.onData((chunk) => {
output += chunk
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
while (
actionIndex < actions.length
&& output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1)
) {
const action = actions[actionIndex]!
if ('writeFile' in action) {
if ('signal' in action) {
terminal.kill(action.signal)
} else if ('writeFile' in action) {
const target = join(cwd, action.writeFile.path)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, action.writeFile.content)
@@ -205,7 +230,8 @@ async function runWindowsPtySmoke(
* @returns complete pseudo-terminal output.
*/
export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
const ownedCwd = options.cwd === undefined
const cwd = options.cwd ?? await mkdtemp(join(tmpdir(), options.tempDirPrefix))
const timeoutMs = options.timeoutMs ?? 25_000
try {
await options.prepare?.(cwd)
@@ -231,6 +257,6 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
await options.inspect?.(cwd)
return output
} finally {
await rm(cwd, { recursive: true, force: true })
if (ownedCwd) await rm(cwd, { recursive: true, force: true })
}
}

View File

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

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783352050756,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783352050756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783352051421,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783352051422,"data":{"turn":1,"step":1,"index":0,"dt":[168,28,0,1,0,0,26,30,0,0,1,0,27,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":23,"time":1783352051790,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":56,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}}
{"type":"assistant/chunk","seq":57,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":58,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":59,"time":1783352052121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"}
{"type":"assistant/message","seq":59,"time":1783352052121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"}
{"type":"tool/call","seq":60,"time":1783352052121,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}
{"type":"tool/result","seq":61,"time":1783352052136,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
{"type":"step/end","seq":62,"time":1783352052137,"data":{"turn":1,"step":1}}
@@ -25,6 +25,6 @@
{"type":"assistant/chunk","seq":91,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":94,"time":1783352052987,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"}
{"type":"assistant/message","seq":94,"time":1783352052987,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"}
{"type":"step/end","seq":95,"time":1783352052987,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":96,"time":1783352052987,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -3,7 +3,7 @@
{"type":"user/message","seq":1,"time":1785052797818,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool exactly once with the command `seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'`, then return ONLY the number of lines in its output. Reply with just that number and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785052797825,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785052797826,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785052797827,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":4,"time":1785052797827,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785052798220,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785052798221,"data":{"turn":1,"step":1,"index":0,"dt":[170,30,0,0,0,30,1,0,0,28,0,0,0,29,30,0,30,0,30,0,0,0,30,0,0,0,0,0,30,30,1],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," run","_code"," program"," that"," calls"," bash"," exactly"," once"," with"," a"," specific"," command",","," then"," returns"," only"," the"," number"," of"," lines"," in"," its"," output","."]}}
{"type":"assistant/chunk","seq":38,"time":1785052798781,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -12,7 +12,7 @@
{"type":"assistant/chunk","seq":153,"time":1785052799793,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}}}}
{"type":"assistant/chunk","seq":154,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}}}}
{"type":"assistant/chunk","seq":155,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"}
{"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"}
{"type":"tool/call","seq":157,"time":1785052799799,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}}
{"type":"tool/code-dispatch-start","seq":158,"time":1785052799893,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"}}}
{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: {{cwd}}/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}}
@@ -27,6 +27,6 @@
{"type":"assistant/chunk","seq":187,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"200"}}}}
{"type":"assistant/chunk","seq":188,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}}}}
{"type":"assistant/chunk","seq":189,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":190,"time":1785052800733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."},{"type":"text","text":"200"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"}
{"type":"assistant/message","seq":190,"time":1785052800733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."},{"type":"text","text":"200"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"}
{"type":"step/end","seq":191,"time":1785052800733,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":192,"time":1785052800733,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -3,7 +3,7 @@
{"type":"user/message","seq":1,"time":1785014512140,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785014512146,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785014512147,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785014512148,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":4,"time":1785014512148,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785014512526,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785014512527,"data":{"turn":1,"step":1,"index":0,"dt":[92,26,0,0,0,27,0,1,20,1,0,0,0,25,1,0,0,0,24,1,24,26,0,24,1,25,0,0,0,1,0,24,0,1,0,0,0,24,1,0,0,0,24,0,1,0,24,1,0,0,0,0,24,1,0,24,0,0,0,1,1,23,0,0,0,0,1,24,25,1,24,1,0,0,0,25,0,25,1,0,0,25,0,0,24,1,0,0,0,25,0,0,24,1,0,25,1,0,0,25,23,26,1,0,0,25,0,0,24,1,0,0,24,0,1,0,24,1,0,0,25,0,0,1,0,0,23,0,1,0,0,0,24,1,0,0,0,0,24,0,0,0,0,1,24,1,0,0,0,0,25,0,0,0,0,1,24,0,0,24,1,0,0,0,24,0,1,0,0,0,33,0,0,0,16,1,0,0,24,1,0,0,0,26,1,0,23,25,0,0,25,1,0,24,0,1,0,0,24,1,0],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Calls"," `","bash","`"," tool"," twice"," -"," first"," with"," `","echo"," CODE","_","ONE","`,"," then"," with"," `","echo"," CODE","_T","WO","`\n","2","."," `","console",".log","`"," exactly"," `","capt","ured"," output","`\n","3","."," Returns"," the"," two"," outputs"," joined"," with"," a"," plus"," sign","\n\n","Let"," me"," think"," about"," the"," structure","."," The"," `","bash","`"," tool"," returns"," an"," object"," with"," stdout","/st","derr","."," I"," need"," to"," extract"," the"," stdout"," text"," from"," each"," call",".\n\n","Looking"," at"," the"," bash"," output"," type",":\n","```\n","{\n"," "," kind",":"," \"","fore","ground","\";\n"," "," exit","Code",":"," number"," |"," null",";\n"," "," signal",":"," string"," |"," null",";\n"," "," timed","Out",":"," boolean",";\n"," "," ab","orted",":"," boolean",";\n"," "," timeout","Ms",":"," number",";\n"," "," stdout",":"," {\n"," "," text",":"," string",";\n"," "," truncated",":"," boolean",";\n"," "," spill","Path","?:"," string",";\n"," "," };\n"," "," st","derr",":"," {"," ..."," };\n"," "," sand","box","?:"," {"," ..."," };\n","}\n","```\n\n","So"," I"," need"," to"," access"," `.","std","out",".text","`"," from"," each"," result",".\n\n","Let"," me"," write"," the"," program","."]}}
{"type":"assistant/chunk","seq":208,"time":1785014513974,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -12,7 +12,7 @@
{"type":"assistant/chunk","seq":340,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}}}}
{"type":"assistant/chunk","seq":341,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}}}}
{"type":"assistant/chunk","seq":342,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":343,"time":1785014514837,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. `console.log` exactly `captured output`\n3. Returns the two outputs joined with a plus sign\n\nLet me think about the structure. The `bash` tool returns an object with stdout/stderr. I need to extract the stdout text from each call.\n\nLooking at the bash output type:\n```\n{\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: { ... };\n sandbox?: { ... };\n}\n```\n\nSo I need to access `.stdout.text` from each result.\n\nLet me write the program."},{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342],"surfaceOp":"append"}
{"type":"assistant/message","seq":343,"time":1785014514837,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. `console.log` exactly `captured output`\n3. Returns the two outputs joined with a plus sign\n\nLet me think about the structure. The `bash` tool returns an object with stdout/stderr. I need to extract the stdout text from each call.\n\nLooking at the bash output type:\n```\n{\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: { ... };\n sandbox?: { ... };\n}\n```\n\nSo I need to access `.stdout.text` from each result.\n\nLet me write the program."},{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342],"surfaceOp":"append"}
{"type":"tool/call","seq":344,"time":1785014514839,"data":{"turn":1,"step":1,"callId":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}}
{"type":"tool/code-dispatch-start","seq":345,"time":1785014514956,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"}}}
{"type":"tool/code-dispatch","seq":346,"time":1785014514990,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}}
@@ -29,6 +29,6 @@
{"type":"assistant/chunk","seq":427,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}}
{"type":"assistant/chunk","seq":428,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}}}}
{"type":"assistant/chunk","seq":429,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":430,"time":1785014516201,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. The output shows:\n- `captured output` (from console.log)\n- `CODE_ONE+CODE_TWO` (the returned joined string)\n\nThe user asked me to reply with that joined string only and stop. So I'll reply with just `CODE_ONE+CODE_TWO`."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}},"sourceEventSeqs":[352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429],"surfaceOp":"append"}
{"type":"assistant/message","seq":430,"time":1785014516201,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. The output shows:\n- `captured output` (from console.log)\n- `CODE_ONE+CODE_TWO` (the returned joined string)\n\nThe user asked me to reply with that joined string only and stop. So I'll reply with just `CODE_ONE+CODE_TWO`."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}},"sourceEventSeqs":[352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429],"surfaceOp":"append"}
{"type":"step/end","seq":431,"time":1785014516202,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":432,"time":1785014516202,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,12 +2,12 @@
{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,12 +2,12 @@
{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,13 +2,13 @@
{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}
{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}
{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}
{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}}
@@ -18,7 +18,7 @@
{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}}}
{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}
{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}}
{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"}
@@ -29,7 +29,7 @@
{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}
{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}
{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}
{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"}
{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}}
@@ -39,7 +39,7 @@
{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}}
{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}
{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}
{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}
{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"}
{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}}
@@ -49,7 +49,7 @@
{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}
{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}
{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}
{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}
{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"}
{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}}
@@ -59,6 +59,6 @@
{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}}
{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}
{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}
{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}}
{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783600638073,"data":{"turn":1,"step":1,"index":0,"dt":[100,16,0,0,0,0,24,0,0,0,0,29,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}}
{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
@@ -11,6 +11,6 @@
{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"}
{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"}
{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","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):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783600634643,"data":{"turn":1,"step":1,"index":0,"dt":[991,0,0,0,0,0,0,0,0,0,1,0,0,0,108,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," workflow"," tool"," exactly"," once"," with"," specific"," parameters","."," Let"," me"," carefully"," follow"," the"," instructions",":\n\n","1","."," args"," omitted"," (","so"," I"," don","'t"," include"," it",")\n","2","."," meta"," ="," {"," \"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\""," }\n","3","."," script"," ="," as"," given"," verb","atim","\n","4","."," After"," it"," returns",","," reply"," with"," \"","WORK","FL","OW","_D","ONE","\"\n\n","Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}}
{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}}
{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"}
{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"}
{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}
{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"}
{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}}
@@ -24,6 +24,6 @@
{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}}
{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}}
{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783352113768,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783352113768,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783352114428,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783352114428,"data":{"turn":1,"step":1,"index":0,"dt":[114,28,1,0,0,1,28,1,1,0,0,1,24,1,29,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]}}
{"type":"assistant/chunk","seq":23,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":26,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}}
{"type":"assistant/chunk","seq":27,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":28,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":29,"time":1783352114690,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"assistant/message","seq":29,"time":1783352114690,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"step/end","seq":30,"time":1783352114690,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":31,"time":1783352114690,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"turn/start","seq":32,"time":1783352114699,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
@@ -26,6 +26,6 @@
{"type":"assistant/chunk","seq":58,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}}
{"type":"assistant/chunk","seq":59,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":60,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":61,"time":1783352115611,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"}
{"type":"assistant/message","seq":61,"time":1783352115611,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"}
{"type":"step/end","seq":62,"time":1783352115611,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":63,"time":1783352115611,"data":{"turn":2,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}}
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"}
{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"}
{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}
{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}
{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"}
@@ -23,6 +23,6 @@
{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}}
{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

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

View File

@@ -0,0 +1,30 @@
{"type":"session","version":0,"id":"main-session","createdAt":1784606400000,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1784606400000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1784606400000,"data":{"content":[{"type":"text","text":"what's the workdir?"}],"source":{"kind":"user"},"role":"user","id":"3fdc2885-1bea-4c6c-b4af-dbd5af7594f8"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784606400000,"data":{"title":"what's the workdir?","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784606400000,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784606400000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1784606400000,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," working"," directory","."," Let"," me"," check"," using"," p","wd","."]}}
{"type":"assistant/chunk","seq":23,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":24,"time0":1784606400000,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd","\"",", ","\"","description","\"",": ","\"","Print"," current"," working"," directory","\"","}"]}}
{"type":"assistant/chunk","seq":46,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."}}}}
{"type":"assistant/chunk","seq":47,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}}}
{"type":"assistant/chunk","seq":48,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":49,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":50,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."},{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"063a9245-32c3-4551-9ace-d43f10ed5582"},"usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"surfaceOp":"append"}
{"type":"tool/call","seq":51,"time":1784606400000,"data":{"turn":1,"step":1,"callId":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}
{"type":"tool/result","seq":52,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_AqoWTncquNel5ZHsJHOo7491"},"content":[{"type":"tool-result","toolCallId":"call_00_AqoWTncquNel5ZHsJHOo7491","content":[{"type":"text","text":"{{cwd}}\n"}],"isError":false}],"role":"user","id":"16086d3b-6dfa-4970-a06e-78561475af8c"}},"sourceEventSeqs":[51],"surfaceOp":"append"}
{"type":"step/end","seq":53,"time":1784606400000,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":54,"time":1784606400000,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":55,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":56,"time0":1784606400000,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is"," `/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","`."]}}
{"type":"assistant/chunk","seq":83,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":84,"time0":1784606400000,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is",":\n\n","```\n","/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","\n","```\n\n","Note"," that"," the"," Deep","Se","ek"," Har","ness"," SDK"," checkout"," is"," at"," `/","opt","/d","sh","-source","`"," —"," that","'s"," a"," separate"," location"," used"," for"," inspecting"," or"," extending"," D","SH"," itself",","," while"," the"," working"," directory"," above"," is"," the"," current"," working"," directory"," for"," this"," session","."]}}
{"type":"assistant/chunk","seq":161,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current working directory is `{{cwd}}`."}}}}
{"type":"assistant/chunk","seq":162,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}}}}
{"type":"assistant/chunk","seq":163,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}}}}
{"type":"assistant/chunk","seq":164,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":165,"time":1784606400000,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current working directory is `{{cwd}}`."},{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"76c18b85-00aa-45f6-a32f-a2bd75412470"},"usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}},"sourceEventSeqs":[55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"}
{"type":"step/end","seq":166,"time":1784606400000,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":167,"time":1784606400000,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,67 @@
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "what's the workdir? — DSH TUI snapshot"
cursor hidden column=7 viewportRow=32 bufferRow=32
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " what's the workdir?"
style 1-19 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "You "
style 0-2 fg=bright-magenta bold underline
5| "what's the workdir? "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Reasoning "
style 0-8 dim italic
9| "The user is asking about the current working directory. Let me check using pwd. "
style 0-78 dim italic
10| <blank>
11| "● Tool / bash / Print current working directory"
style 0-46 fg=green
12| "$ pwd "
style 0-4 dim
13| "/workspace/project "
style 0-17 dim
14| "[exit 0] "
style 0-7 dim
15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
16| <blank>
17| "Assistant "
style 0-8 fg=bright-magenta bold underline
18| "Reasoning "
style 0-8 dim italic
19| "The current working directory is /workspace/project. "
style 0-32 dim italic
style 33-84 fg=cyan
style 85-85 dim italic
20| "The current working directory is: "
21| " "
22| " "
23| " /workspace/project "
style 2-53 fg=cyan
24| " "
25| " "
26| "Note that the DeepSeek Harness SDK checkout is at /opt/dsh-source — that's a separate location used "
style 50-64 fg=cyan
27| "for inspecting or extending DSH itself, while the working directory above is the current working "
28| "directory for this session. "
29| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
30| <blank>
31| "/workspace/project deepseek-v4-flash ↑3.4k ↓185 cache 49% 3% c"
style 0-51 fg=bright-magenta bold
style 54-70 dim
style 73-93 dim
style 96-99 dim
32| " dsh ◍ "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
33-35| <blank>

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783352058320,"data":{"turn":1,"step":1,"index":0,"dt":[106,40,1,0,0,0,17,0,0,0,1,26,1,1,0,0,1,26,0,31,1,25,0,0,0,29,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," todo","_write"," tool"," to"," record"," a"," plan"," with"," exactly"," three"," todos"," in"," the"," specified"," status","es",","," then"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":36,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":92,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}}
{"type":"assistant/chunk","seq":93,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}}
{"type":"assistant/chunk","seq":94,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":95,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"}
{"type":"assistant/message","seq":95,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"}
{"type":"tool/call","seq":96,"time":1783352059099,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}
{"type":"todo/write","seq":97,"time":1783352059100,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}}
{"type":"tool/result","seq":98,"time":1783352059101,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[96],"surfaceOp":"append"}
@@ -26,6 +26,6 @@
{"type":"assistant/chunk","seq":127,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}}
{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":130,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"assistant/message","seq":130,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"step/end","seq":131,"time":1783352059981,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":132,"time":1783352059981,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { resolveTelemetryPatch } from '../src/app-cli-entry.ts'
describe('resolveTelemetryPatch', () => {
it('keeps telemetry enabled when the switch is unset or empty', () => {
expect(resolveTelemetryPatch(undefined, true)).toBeUndefined()
expect(resolveTelemetryPatch('', true)).toBeUndefined()
})
it('disables on ANY non-empty value, including falsy-looking ones', () => {
for (const value of ['1', '0', 'false', 'no']) {
expect(resolveTelemetryPatch(value, true)).toEqual({ id: 'telemetry-otel', disabled: true })
}
})
it('fails loud when the switch is set but the row is absent', () => {
expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition')
})
it('ignores a missing row while the switch is unset', () => {
expect(resolveTelemetryPatch(undefined, false)).toBeUndefined()
})
})

View File

@@ -0,0 +1,76 @@
overlay 120x30 rows=20
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-119 dim
1| "│ ▄ DeepSeek Harness │"
style 0-0 dim
style 10-37 fg=blue
style 75-90 fg=blue bold
style 119-119 dim
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
style 0-0 dim
style 9-38 fg=blue
style 119-119 dim
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能 │"
style 0-0 dim
style 4-43 fg=blue
style 119-119 dim
4| "│ ▄███████████████████▄ ████████████▀ 仍待完善,体验难免有些粗糙。 │"
style 0-0 dim
style 4-43 fg=blue
style 119-119 dim
5| "│ ▄██████████████████████▄ ▀█████████▀ │"
style 0-0 dim
style 4-42 fg=blue
style 119-119 dim
6| "│ ▄███▀█████████████████████▄ ████▀▀ “如切如磋,如琢如磨。” │"
style 0-0 dim
style 6-41 fg=blue
style 49-70 bold
style 119-119 dim
7| "│ ███ ▀▀█████████▀▀▀█████████▀ │"
style 0-0 dim
style 7-40 fg=blue
style 119-119 dim
8| "│ ███ ▀███████▀█ ▀███████ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │"
style 0-0 dim
style 7-39 fg=blue
style 119-119 dim
9| "│ ███▄ ▀███████▄ ▀█████▀ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │"
style 0-0 dim
style 7-39 fg=blue
style 119-119 dim
10| "│ ▀███ ▀██████████████ │"
style 0-0 dim
style 8-39 fg=blue
style 119-119 dim
11| "│ ▀███▄ ▀███████████▀ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 │"
style 0-0 dim
style 8-38 fg=blue
style 119-119 dim
12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log如需关闭请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外 │"
style 0-0 dim
style 9-38 fg=blue
style 119-119 dim
13| "│ █████▄ ███▄▄ ▀█████▄▄ ,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈, │"
style 0-0 dim
style 9-38 fg=blue
style 119-119 dim
14| "│ ▀█████████████▄▄▄▄█▀█████▀ 都会帮助我们把它打磨得更好。 │"
style 0-0 dim
style 8-39 fg=blue
style 119-119 dim
15| "│ ▀▀███████████▀▀ │"
style 0-0 dim
style 12-34 fg=blue
style 119-119 dim
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
style 0-119 dim
17| "│ Enter 继续 │"
style 0-0 dim
style 54-64 fg=bright-magenta bold
style 119-119 dim
18| "│ │"
style 0-0 dim
style 119-119 dim
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-119 dim

View File

@@ -0,0 +1,76 @@
overlay 160x30 rows=20
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-159 dim
1| "│ ▄ DeepSeek Harness │"
style 0-0 dim
style 10-37 fg=blue
style 95-110 fg=blue bold
style 159-159 dim
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
style 0-0 dim
style 9-38 fg=blue
style 159-159 dim
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。 │"
style 0-0 dim
style 4-43 fg=blue
style 159-159 dim
4| "│ ▄███████████████████▄ ████████████▀ │"
style 0-0 dim
style 4-43 fg=blue
style 159-159 dim
5| "│ ▄██████████████████████▄ ▀█████████▀ “如切如磋,如琢如磨。” │"
style 0-0 dim
style 4-42 fg=blue
style 49-70 bold
style 159-159 dim
6| "│ ▄███▀█████████████████████▄ ████▀▀ │"
style 0-0 dim
style 6-41 fg=blue
style 159-159 dim
7| "│ ███ ▀▀█████████▀▀▀█████████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │"
style 0-0 dim
style 7-40 fg=blue
style 159-159 dim
8| "│ ███ ▀███████▀█ ▀███████ 有的设计。 │"
style 0-0 dim
style 7-39 fg=blue
style 159-159 dim
9| "│ ███▄ ▀███████▄ ▀█████▀ │"
style 0-0 dim
style 7-39 fg=blue
style 159-159 dim
10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭请设置环境变量 │"
style 0-0 dim
style 8-39 fg=blue
style 159-159 dim
11| "│ ▀███▄ ▀███████████▀ DSH_TELEMETRY_DISABLED=1。另外如果您有任何反馈与建议请在企业微信群中留言告诉我们。每一条反馈都会帮助我 │"
style 0-0 dim
style 8-38 fg=blue
style 159-159 dim
12| "│ ▀███▄ ▄▄▄ ▀████████▀ 们把它打磨得更好。 │"
style 0-0 dim
style 9-38 fg=blue
style 159-159 dim
13| "│ █████▄ ███▄▄ ▀█████▄▄ │"
style 0-0 dim
style 9-38 fg=blue
style 159-159 dim
14| "│ ▀█████████████▄▄▄▄█▀█████▀ │"
style 0-0 dim
style 8-39 fg=blue
style 159-159 dim
15| "│ ▀▀███████████▀▀ │"
style 0-0 dim
style 12-34 fg=blue
style 159-159 dim
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
style 0-159 dim
17| "│ Enter 继续 │"
style 0-0 dim
style 74-84 fg=bright-magenta bold
style 159-159 dim
18| "│ │"
style 0-0 dim
style 159-159 dim
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-159 dim

View File

@@ -0,0 +1,31 @@
overlay 60x12 rows=10
0| "╭──────────────────────────────────────────────────────────╮"
style 0-59 dim
1| "│ DeepSeek Harness │"
style 0-0 dim
style 22-37 fg=blue bold
style 59-59 dim
2| "│ │"
style 0-0 dim
style 59-59 dim
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
style 0-0 dim
style 59-59 dim
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
style 0-0 dim
style 59-59 dim
5| "│ │"
style 0-0 dim
style 59-59 dim
6| "├──────────────────────────────────────────────────────────┤"
style 0-59 dim
7| "│ Enter 继续 │"
style 0-0 dim
style 24-34 fg=bright-magenta bold
style 59-59 dim
8| "│ ↑/↓ 滚动 ↓ │"
style 0-0 dim
style 24-35 dim
style 59-59 dim
9| "╰──────────────────────────────────────────────────────────╯"
style 0-59 dim

View File

@@ -0,0 +1,64 @@
overlay 60x30 rows=21
0| "╭──────────────────────────────────────────────────────────╮"
style 0-59 dim
1| "│ DeepSeek Harness │"
style 0-0 dim
style 22-37 fg=blue bold
style 59-59 dim
2| "│ │"
style 0-0 dim
style 59-59 dim
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
style 0-0 dim
style 59-59 dim
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
style 0-0 dim
style 59-59 dim
5| "│ │"
style 0-0 dim
style 59-59 dim
6| "│ “如切如磋,如琢如磨。” │"
style 0-0 dim
style 2-23 bold
style 59-59 dim
7| "│ │"
style 0-0 dim
style 59-59 dim
8| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │"
style 0-0 dim
style 59-59 dim
9| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │"
style 0-0 dim
style 59-59 dim
10| "│ 设计。 │"
style 0-0 dim
style 59-59 dim
11| "│ │"
style 0-0 dim
style 59-59 dim
12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │"
style 0-0 dim
style 59-59 dim
13| "│ 会上传所有 Session Log如需关闭请设置环境变量 │"
style 0-0 dim
style 59-59 dim
14| "│ DSH_TELEMETRY_DISABLED=1。另外如果您有任何反馈与建议 │"
style 0-0 dim
style 59-59 dim
15| "│ 请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把 │"
style 0-0 dim
style 59-59 dim
16| "│ 它打磨得更好。 │"
style 0-0 dim
style 59-59 dim
17| "├──────────────────────────────────────────────────────────┤"
style 0-59 dim
18| "│ Enter 继续 │"
style 0-0 dim
style 24-34 fg=bright-magenta bold
style 59-59 dim
19| "│ │"
style 0-0 dim
style 59-59 dim
20| "╰──────────────────────────────────────────────────────────╯"
style 0-59 dim

View File

@@ -0,0 +1,89 @@
overlay 80x30 rows=27
0| "╭──────────────────────────────────────────────────────────────────────────────╮"
style 0-79 dim
1| "│ ▄▄▄▄▄▄ ▄▄ │"
style 0-0 dim
style 33-46 fg=blue
style 79-79 dim
2| "│ ▄████████▄ ▀████▀ │"
style 0-0 dim
style 31-48 fg=blue
style 79-79 dim
3| "│ █▀▀▀▀███████▄██▀ │"
style 0-0 dim
style 32-47 fg=blue
style 79-79 dim
4| "│ █▄ ▀███ ▀███ │"
style 0-0 dim
style 32-46 fg=blue
style 79-79 dim
5| "│ ▀█▄ ▀█████ │"
style 0-0 dim
style 33-46 fg=blue
style 79-79 dim
6| "│ ▀█▄▄ █▄▄▀███▄ │"
style 0-0 dim
style 33-46 fg=blue
style 79-79 dim
7| "│ ▀▀▀▀▀▀ │"
style 0-0 dim
style 35-44 fg=blue
style 79-79 dim
8| "│ │"
style 0-0 dim
style 79-79 dim
9| "│ DeepSeek Harness │"
style 0-0 dim
style 32-47 fg=blue bold
style 79-79 dim
10| "│ │"
style 0-0 dim
style 79-79 dim
11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完 │"
style 0-0 dim
style 79-79 dim
12| "│ 善,体验难免有些粗糙。 │"
style 0-0 dim
style 79-79 dim
13| "│ │"
style 0-0 dim
style 79-79 dim
14| "│ “如切如磋,如琢如磨。” │"
style 0-0 dim
style 2-23 bold
style 79-79 dim
15| "│ │"
style 0-0 dim
style 79-79 dim
16| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │"
style 0-0 dim
style 79-79 dim
17| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │"
style 0-0 dim
style 79-79 dim
18| "│ │"
style 0-0 dim
style 79-79 dim
19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │"
style 0-0 dim
style 79-79 dim
20| "│ Log如需关闭请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外如果您有任何反 │"
style 0-0 dim
style 79-79 dim
21| "│ 馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │"
style 0-0 dim
style 79-79 dim
22| "│ 好。 │"
style 0-0 dim
style 79-79 dim
23| "├──────────────────────────────────────────────────────────────────────────────┤"
style 0-79 dim
24| "│ Enter 继续 │"
style 0-0 dim
style 34-44 fg=bright-magenta bold
style 79-79 dim
25| "│ │"
style 0-0 dim
style 79-79 dim
26| "╰──────────────────────────────────────────────────────────────────────────────╯"
style 0-79 dim

View File

@@ -0,0 +1,346 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { visibleWidth } from '@earendil-works/pi-tui'
import {
type TuiOverlayHost,
type TuiOverlayRequest,
type TuiTheme,
} from '@deepseek-ai/dsh-tui'
import {
acknowledgeTuiFirstRunWelcome,
apply,
hasTuiFirstRunWelcomeAcknowledgement,
needsTuiFirstRunWelcomeAsciiArt,
TuiFirstRunWelcomeComponent,
tuiFirstRunWelcomeAcknowledgementPath,
tuiFirstRunWelcomeArtTier,
} from '../src/tui-onboarding/tui-first-run-welcome.ts'
import {
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
const mockDisposeRootAndExit = vi.hoisted(() => vi.fn())
vi.mock('@deepseek-ai/dsh-tui', async importOriginal => ({
...await importOriginal<typeof import('@deepseek-ai/dsh-tui')>(),
disposeRootAndExit: mockDisposeRootAndExit,
}))
const identityTheme: TuiTheme = Object.freeze({
text: (value: string) => value,
brand: (value: string) => value,
dim: (value: string) => value,
accent: (value: string) => value,
success: (value: string) => value,
warning: (value: string) => value,
error: (value: string) => value,
bold: (value: string) => value,
})
function hostFixture(rows: number): {
host: TuiOverlayHost
closed: () => boolean
invalidations: () => number
} {
let closed = false
let invalidations = 0
const controller = new AbortController()
return {
host: Object.freeze({
signal: controller.signal,
viewport: Object.freeze({ columns: 160, rows }),
theme: identityTheme,
display: (value: string) => value,
invalidate: () => { invalidations += 1 },
close: () => { closed = true },
}),
closed: () => closed,
invalidations: () => invalidations,
}
}
const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
const openingSentence = `${copy.paragraphs[0]!.split('。', 1)[0]}`
const temporaryHomes: string[] = []
function artAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
}
function withoutWhitespace(value: string): string {
return value.replace(/\s/gu, '')
}
async function temporaryHome(prefix: string): Promise<string> {
const home = await mkdtemp(join(tmpdir(), prefix))
temporaryHomes.push(home)
return home
}
afterEach(async () => {
mockDisposeRootAndExit.mockClear()
await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true })))
})
describe('TUI first-run welcome acknowledgement', () => {
it('publishes one immutable per-version marker safely across concurrent acknowledgements', async () => {
const home = await temporaryHome('dsh-tui-welcome-ack-')
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(false)
await Promise.all(Array.from({ length: 8 }, () => acknowledgeTuiFirstRunWelcome(home)))
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
const info = await stat(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION))
expect(info.isFile()).toBe(true)
if (process.platform !== 'win32') expect(info.mode & 0o777).toBe(0o600)
})
it('treats a notice-version bump as a new one-time acknowledgement', async () => {
const home = await temporaryHome('dsh-tui-welcome-version-')
await acknowledgeTuiFirstRunWelcome(home)
const nextVersion = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION + 1
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(false)
await acknowledgeTuiFirstRunWelcome(home, nextVersion)
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(true)
})
it('rejects a malformed marker instead of silently acknowledging it', async () => {
const home = await temporaryHome('dsh-tui-welcome-malformed-')
await mkdir(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION), {
recursive: true,
})
await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file')
await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow()
})
it('detects only explicit ASCII-only terminal environments', () => {
expect(needsTuiFirstRunWelcomeAsciiArt({ TERM: 'dumb' })).toBe(true)
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_ALL: 'C' })).toBe(true)
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_CTYPE: 'POSIX' })).toBe(true)
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'C' })).toBe(true)
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'en_US.UTF-8' })).toBe(false)
expect(typeof needsTuiFirstRunWelcomeAsciiArt()).toBe('boolean')
})
})
describe('TUI first-run welcome composition', () => {
it('pins the supplied official icon and exact Chinese copy at their owner boundaries', async () => {
const icon = (await readFile(new URL('../assets/deepseek-color.svg', import.meta.url), 'utf8')).trimEnd()
expect(createHash('sha256').update(icon).digest('hex'))
.toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439')
expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex'))
.toBe('99f9a828b4f083b28de21bf5e03f939c00238531e765db78911957c44c6e98da')
expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy)
})
it.each([
{ columns: 60, inner: 50, rows: 30, tier: undefined },
{ columns: 80, inner: 68, rows: 30, tier: 'minimal' },
{ columns: 100, inner: 84, rows: 34, tier: 'compact' },
{ columns: 120, inner: 104, rows: 30, tier: 'full' },
{ columns: 160, inner: 140, rows: 30, tier: 'full' },
] as const)('renders the responsive composition at $columns columns without overdraw', ({ inner, rows, tier }) => {
const fixture = hostFixture(rows)
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
const renderWidth = inner + 4
const lines = component.render(renderWidth)
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true)
if (tier === undefined) {
expect(lines.join('\n')).not.toMatch(/[]/u)
} else {
expect(lines.join('\n')).toContain(artAnchor(tier))
}
const rendered = lines.join('\n')
const optOut = copy.paragraphs.at(-1)!.match(/[A-Z_]+=1/u)![0]
expect(rendered).not.toContain(copy.scrollHint)
expect(rendered).toContain(copy.paragraphs.at(-1)!.match(/[A-Za-z]+ [A-Za-z]+/u)![0])
expect(rendered).toContain(optOut)
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
expect(lines.length).toBeGreaterThan(5)
})
it.each([
{ inner: 68, rows: 14, tier: undefined },
{ inner: 68, rows: 17, tier: undefined },
{ inner: 68, rows: 18, tier: 'minimal' },
{ inner: 84, rows: 21, tier: 'minimal' },
{ inner: 84, rows: 22, tier: 'compact' },
] as const)('degrades art to preserve the action at $rows rows', ({ inner, rows, tier }) => {
const fixture = hostFixture(rows)
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
const lines = component.render(inner + 4)
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
})
it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => {
const fixture = hostFixture(10)
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
const initial = component.render(54).join('\n')
expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined()
expect(initial).toContain(openingSentence)
expect(initial).toContain(`Enter ${copy.continueLabel}`)
component.handleInput('\x1b[F')
const end = component.render(54).join('\n')
expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-7)))
expect(end).toContain(`Enter ${copy.continueLabel}`)
for (const key of ['\x1b[A', '\x1b[B', '\x1b[5~', '\x1b[6~', '\x1b[H', 'x']) {
component.handleInput(key)
}
component.invalidate()
})
it('renders a tiny viewport and a quotation-only paragraph without overdraw', () => {
const fixture = hostFixture(5)
const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] }
const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}, () => {})
const lines = component.render(2)
expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true)
})
it('keeps the side-by-side composition aligned when prose outgrows the full raster', () => {
const fixture = hostFixture(40)
const longCopy = { ...copy, paragraphs: [copy.paragraphs.join(' ').repeat(4)] }
const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}, () => {})
const lines = component.render(100)
expect(lines.length).toBeGreaterThan(TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length)
expect(lines.every(line => visibleWidth(line) <= 100)).toBe(true)
component.handleInput('\x1b[F')
expect(component.render(100).join('\n')).toContain(copy.title)
})
it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => {
const fixture = hostFixture(30)
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}, true)
const rendered = component.render(72).join('\n')
expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
expect(rendered).not.toMatch(/[]/u)
})
it.each(['full', 'compact', 'minimal'] as const)('keeps the $tier ASCII raster bit-equivalent', (tier) => {
const mapped = TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode.map(line => Array.from(line).map((cell) => {
if (cell === '▀') return "'"
if (cell === '▄') return '_'
if (cell === '█') return '#'
return cell
}).join(''))
expect(mapped).toEqual(TUI_FIRST_RUN_WELCOME_WHALE[tier].ascii)
})
it('ignores Escape and acknowledges only Enter before closing', async () => {
const fixture = hostFixture(30)
const acknowledge = vi.fn(async () => {})
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
component.render(72)
component.handleInput('\x1b')
await Promise.resolve()
expect(acknowledge).not.toHaveBeenCalled()
expect(fixture.closed()).toBe(false)
component.handleInput('\r')
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
expect(acknowledge).toHaveBeenCalledOnce()
})
it('keeps the notice eligible when Ctrl+C or Ctrl+D requests a normal exit', async () => {
const fixture = hostFixture(30)
const acknowledge = vi.fn(async () => {})
const exit = vi.fn()
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, exit)
component.handleInput('\x03')
component.handleInput('\x04')
expect(exit).toHaveBeenCalledTimes(2)
expect(acknowledge).not.toHaveBeenCalled()
expect(fixture.closed()).toBe(false)
})
it('does not start a second acknowledgement while the first Enter is pending', async () => {
const fixture = hostFixture(30)
const pending = Promise.withResolvers<undefined>()
const acknowledge = vi.fn(async () => pending.promise)
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
component.render(72)
component.handleInput('\r')
component.handleInput('\r')
component.handleInput('\x1b[B')
expect(component.render(72).join('\n')).toContain(copy.saving)
expect(acknowledge).toHaveBeenCalledOnce()
pending.resolve(undefined)
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
})
it('keeps the overlay open after a persistence failure and lets Enter retry', async () => {
const fixture = hostFixture(30)
let attempts = 0
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {
attempts += 1
if (attempts === 1) throw new Error('disk unavailable')
}, () => {})
component.render(72)
component.handleInput('\r')
await vi.waitFor(() => {
expect(component.render(72).join('\n')).toContain(copy.saveError)
})
expect(fixture.closed()).toBe(false)
component.handleInput('\r')
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
expect(attempts).toBe(2)
expect(fixture.invalidations()).toBeGreaterThanOrEqual(3)
})
it('opens through the TUI extension and uses the launcher-owned acknowledgement closure', async () => {
const home = await temporaryHome('dsh-tui-welcome-apply-')
let request: TuiOverlayRequest | undefined
let disposePending: (() => Promise<void>) | undefined
const ctx = {
effect(register: () => () => Promise<void>) {
disposePending = register()
return () => {}
},
tui: {
openOverlay(value: TuiOverlayRequest) {
request = value
return {} as never
},
},
} as unknown as Context
apply(ctx, { dshHome: home })
expect(request?.options).toEqual({
width: '100%',
maxHeight: '90%',
anchor: 'center',
margin: 0,
})
const fixture = hostFixture(30)
const component = request?.create(fixture.host)
expect(component).toBeInstanceOf(TuiFirstRunWelcomeComponent)
component?.handleInput?.('\x03')
expect(mockDisposeRootAndExit).toHaveBeenCalledWith(ctx, 0)
component?.handleInput?.('\r')
await disposePending?.()
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
apply(ctx, { dshHome: home, asciiArt: true })
expect(request?.create(fixture.host).render(72).join('\n'))
.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
})
})

View File

@@ -1,19 +1,43 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { createHash } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts'
import {
acknowledgeTuiFirstRunWelcome,
hasTuiFirstRunWelcomeAcknowledgement,
} from '../src/tui-onboarding/tui-first-run-welcome.ts'
import {
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
// `--config` layers an overlay over the shared base, so the default surface
// needs no config argument at all; these are the overlays under test.
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects
// while the TUI already holds the terminal.
const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url))
const synchronizedFrameEnd = '\x1b[?2026l'
// Artifact mode gives the inner PTY driver 60 seconds and its execa owner a
// five-second backstop. Keep Vitest outside both deadlines so the harness can
// report its own marker, exit, and cleanup failure instead of being cut off.
const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
? 75_000
: LOADER_SMOKE_TEST_TIMEOUT_MS
/**
* Seed the isolated process workspace: ordinary files land in `cwd`, personal
@@ -47,6 +71,38 @@ function seedWorkspace(
}
}
/**
* Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin`
* package and return the generated wrapper text, so the smoke's cache-seeded
* wrapper can never drift from the generator's template.
*/
async function generatePreparedWrapper(pluginName: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-'))
try {
const plugin = join(root, '.dsh-plugin')
await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true })
await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [
'---',
'name: config-only-repository',
'description: Generator input; the seeded cache copy owns the visible text.',
'---',
'',
'Repository instructions.',
'',
].join('\n'))
await mkdir(plugin, { recursive: true })
await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
name: pluginName,
version: '0.0.0',
dsh: { skills: ['../skills'] },
}, undefined, 2)}\n`)
await prepareDshPlugin(plugin)
return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
} finally {
await rm(root, { recursive: true, force: true })
}
}
/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
async function seedResumeSession(cwd: string): Promise<void> {
const sessionCwd = realpathSync.native(cwd)
@@ -125,18 +181,77 @@ async function readLoggedRequestContext(cwd: string): Promise<LoggedRequestConte
* `tui.cordis.yml`, with no flags) or `configPath` (an overlay layered over that
* same base through `--config`).
*/
function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
function smoke(overrides: Partial<TuiPtySmokeOptions> & {
label: string
showFirstRunWelcome?: boolean
}): Promise<string> {
const { showFirstRunWelcome = false, prepare, ...options } = overrides
return runTuiPtySmoke({
tempDirPrefix: 'dsh-tui-smoke-',
binScript: dshBinScript,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
env: {
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
DSH_TELEMETRY_DISABLED: '1',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
LC_CTYPE: 'en_US.UTF-8',
TERM: 'xterm-256color',
},
// Artifact CI builds and smokes concurrently on a contended runner.
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
...overrides,
...options,
prepare: async (cwd) => {
if (!showFirstRunWelcome) await acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh'))
await prepare?.(cwd)
},
})
}
const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}`
function firstRunArtAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
}
/** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */
function overlaySnapshot(snapshot: string, columns: number, rows: number): string {
const blocks: string[][] = []
for (const line of snapshot.split('\n')) {
if (/^\d+(?:-\d+)?~?\| /u.test(line)) blocks.push([line])
else if (line.startsWith(' style ') && blocks.length > 0) blocks.at(-1)?.push(line)
}
const first = blocks.findIndex(block => block[0]?.includes('╭') === true)
const last = blocks.findIndex((block, index) => index >= first && block[0]?.includes('╰') === true)
if (first < 0 || last < first) throw new Error('first-run PTY snapshot has no complete overlay frame')
const overlay = blocks.slice(first, last + 1).flatMap((block, index) => [
block[0]!.replace(/^\d+(?:-\d+)?(~)?\|/u, `${String(index)}$1|`),
...block.slice(1),
])
return [`overlay ${String(columns)}x${String(rows)} rows=${String(last - first + 1)}`, ...overlay, ''].join('\n')
}
/** Project the first synchronized PTY frame containing `marker` into an overlay-only snapshot. */
async function firstRunFrameSnapshot(
output: string,
marker: string,
columns: number,
rows: number,
): Promise<string> {
const markerIndex = output.indexOf(marker)
if (markerIndex < 0) throw new Error(`first-run PTY output has no marker ${JSON.stringify(marker)}`)
const frameEnd = output.indexOf(synchronizedFrameEnd, markerIndex)
if (frameEnd < 0) throw new Error(`first-run PTY output has no complete frame after ${JSON.stringify(marker)}`)
const terminal = new HeadlessTerminal(columns, rows)
try {
terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length))
return overlaySnapshot(await terminal.snapshot(), columns, rows)
} finally {
await terminal.dispose()
}
}
// The scripted conversation switches to the pro model first: the scripted
// adapter proves routing + prompt variables by rejecting tool-ful calls on any
// other route (see fixtures/tui-scripted-llm.ts).
@@ -146,6 +261,138 @@ const SELECT_PRO_MODEL = [
] as const
describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
it.each([
{ columns: 60, tier: undefined },
{ columns: 80, tier: 'minimal' },
{ columns: 120, tier: 'full' },
{ columns: 160, tier: 'full' },
] as const)('renders and acknowledges the responsive first-run composition at $columns columns', async ({ columns, tier }) => {
const output = await smoke({
label: `dsh first-run welcome ${String(columns)} columns`,
tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`,
configPath: scriptedConfigPath,
showFirstRunWelcome: true,
expectedExitCode: 0,
columns,
rows: 30,
actions: [
{
waitFor: `Enter ${firstRunCopy.continueLabel}`,
send: '\r\x03',
},
],
inspect: async (cwd) => {
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true)
const entries = await readdir(join(cwd, '.sessions'), { recursive: true })
const logs = entries.filter(name => name.endsWith('.jsonl'))
for (const log of logs) {
const stored = await readFile(join(cwd, '.sessions', log), 'utf8')
expect(stored).not.toContain(firstRunCopy.paragraphs[0])
}
},
})
await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, columns, 30))
.toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`))
if (tier === undefined) {
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
} else {
expect(output).toContain(firstRunArtAnchor(tier))
}
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => {
const output = await smoke({
label: 'dsh low-height first-run welcome',
tempDirPrefix: 'dsh-tui-welcome-low-',
configPath: scriptedConfigPath,
showFirstRunWelcome: true,
expectedExitCode: 0,
columns: 60,
rows: 12,
actions: [
{ waitFor: firstRunOpeningSentence, send: '\x1b[F' },
{
waitFor: `Enter ${firstRunCopy.continueLabel}`,
occurrence: 2,
send: '\r\x03',
},
],
})
await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, 60, 12))
.toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt'))
expect(output).toContain(firstRunCopy.title)
expect(output).toContain(firstRunOpeningSentence)
expect(output).toContain('企业微信群')
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('shows once and skips the second launch under the same DSH_HOME', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-twice-'))
try {
const first = await smoke({
label: 'dsh first welcome launch',
tempDirPrefix: 'unused-',
cwd,
configPath: scriptedConfigPath,
showFirstRunWelcome: true,
expectedExitCode: 0,
actions: [
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
],
})
expect(first).toContain(firstRunCopy.title)
const second = await smoke({
label: 'dsh second welcome launch',
tempDirPrefix: 'unused-',
cwd,
configPath: scriptedConfigPath,
showFirstRunWelcome: true,
expectedExitCode: process.platform === 'win32' ? 0 : -15,
actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }],
})
expect(second).not.toContain(firstRunOpeningSentence)
expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`)
} finally {
await rm(cwd, { recursive: true, force: true })
}
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it.skipIf(process.platform === 'win32')('keeps the notice eligible when the process exits before Enter', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-abort-'))
try {
await smoke({
label: 'dsh aborted welcome launch',
tempDirPrefix: 'unused-',
cwd,
configPath: scriptedConfigPath,
showFirstRunWelcome: true,
expectedExitCode: -15,
actions: [{ waitFor: firstRunOpeningSentence, signal: 'SIGTERM' }],
inspect: async (workspace) => {
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false)
},
})
const next = await smoke({
label: 'dsh welcome after aborted launch',
tempDirPrefix: 'unused-',
cwd,
configPath: scriptedConfigPath,
showFirstRunWelcome: true,
expectedExitCode: 0,
actions: [
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
],
})
expect(next).toContain(firstRunOpeningSentence)
} finally {
await rm(cwd, { recursive: true, force: true })
}
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => {
// With no configured welcome the borderless banner sweeps in left-to-right;
// the detail line's session id (`main-session-<uuid>`) renders only once
@@ -168,6 +415,28 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).not.toContain('╭')
expect(output).not.toContain('╮')
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
// The Loader mounts entries concurrently, so `ui-tui` can already own the
// terminal when a sibling entry rejects on its config. Exiting without the
// tree's own teardown left raw mode and bracketed paste set on the user's
// shell, and the pending Device Attributes reply landed there as literal
// text. The transactional mount must settle (an HMR initial-scan refresh
// once deadlocked its rollback into a silent exit 13) so `boot` disposes
// the tree — reaching the TUI's own shutdown — and rejects with the
// labelled diagnostic.
it('restores the terminal when a sibling entry fails to validate during boot', async () => {
const output = await smoke({
label: 'dsh invalid provider config',
tempDirPrefix: 'dsh-tui-invalid-config-',
configPath: invalidProviderConfigPath,
expectedExitCode: 1,
})
expect(output).toContain('dsh: plugin tree failed to load:')
expect(output).toContain('$.providers')
// Bracketed paste is disabled again, which only `ProcessTerminal.stop()`
// writes — proof the tree was disposed rather than exited out from under.
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
@@ -221,7 +490,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).toContain('Registered tools')
expect(output).toContain('ask_user_question')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
// The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
@@ -257,7 +526,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).not.toContain('[instructions]')
expect(output).toContain('Scripted skill body received.')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('adds a watched local skill to live /skill: autocomplete without restarting', async () => {
const skill = [
@@ -287,7 +556,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
})
expect(output).toContain('HOT_ADDED_COMPLETION_MARKER')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => {
const output = await smoke({
@@ -312,11 +581,51 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).toContain('File · terminal-special-case.t')
expect(output).toContain('@src/terminal-special-case.ts')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
})
describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
it('shows the terminal-local notice over a resumed session without changing its log', async () => {
let originalLineCount = 0
const output = await smoke({
label: 'dsh first-run notice on resume',
tempDirPrefix: 'dsh-tui-welcome-resume-',
binScript: dshBinScript,
configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath],
showFirstRunWelcome: true,
expectedExitCode: 0,
prepare: async (cwd) => {
await seedResumeSession(cwd)
const before = await readFile(logPath(
join(cwd, '.sessions'),
realpathSync.native(cwd),
SessionId('resume-target'),
'none',
), 'utf8')
originalLineCount = before.split('\n').filter(Boolean).length
},
actions: [
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
],
inspect: async (cwd) => {
const after = await readFile(logPath(
join(cwd, '.sessions'),
realpathSync.native(cwd),
SessionId('resume-target'),
'none',
), 'utf8')
expect(after).not.toContain(firstRunCopy.paragraphs[0])
const appended = after.split('\n').filter(Boolean).slice(originalLineCount)
.map(line => JSON.parse(line) as SessionEvent)
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' }))
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' }))
},
})
expect(output).toContain(firstRunOpeningSentence)
expect(output).toContain('Resume selector design — DeepSeek Harness')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('exec-replaces the TUI for /resume and restores the same session state', async () => {
const output = await smoke({
label: 'dsh in-place resume',
@@ -336,7 +645,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(released).toBeGreaterThanOrEqual(0)
expect(restored).toBeGreaterThan(released)
expect(output).toContain('Preserve restored state')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('boots the shipped default config with no arguments and no personal overlay', async () => {
const output = await smoke({
@@ -351,36 +660,90 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).not.toContain('╭')
expect(output).not.toContain('╮')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => {
// The whole personal-config chain in one boot: the personal .env supplies
// the variable, config.yaml patches the `tui` row — a row the SURFACE
// OVERLAY inserted, not one the base declares — with a `!!js` reference to
// it, and the banner renders the patched welcome verbatim. That proves a
// later patch list reaches a row an earlier one inserted.
it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
// The whole personal-config chain in one boot, plus the environment layer
// it deliberately excludes. config.yaml patches the `tui` row — a row the
// SURFACE OVERLAY inserted, not one the base declares — proving a later
// patch list reaches a row an earlier one inserted. The single `!!js`
// expression prefers the PERSONAL variable, so the welcome can only render
// the project value while the harness home's .env — the credential store
// of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting
// it would make every stored key read as a read-only launch override on
// the next run and hand it to every subprocess the agent starts.
const output = await smoke({
label: 'dsh personal overlay',
tempDirPrefix: 'dsh-personal-overlay-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' },
personal: {
'.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
'.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n',
'config.yaml': [
'- id: workspace-context',
' disabled: true',
'- id: tui',
' config:',
" sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'",
' welcome: !!js process.env.DSH_PERSONAL_WELCOME',
' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME',
'',
].join('\n'),
},
}),
actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }],
actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }],
})
expect(output).toContain('PERSONAL OVERLAY READY.')
expect(output).toContain('PROJECT OVERLAY READY.')
expect(output).not.toContain('HOME ENV LEAKED.')
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('loads a cached repository Plugin from personal config alone', async () => {
const source = 'github:fixture/repository#fixed-ref'
const specifier = `${source}&path:/.dsh-plugin`
const key = createHash('sha256').update(specifier).digest('hex')
const packageRoot = `cache/repository-plugins/${key}/node_modules/repository`
// Produced by the real generator (prepareDshPlugin over an equivalent
// .dsh-plugin package) rather than hand-written, so a wrapper-template
// change cannot leave this smoke exercising a stale shape. The cache
// LAYOUT below (sha256 key, marker, node_modules/repository) remains a
// deliberate external pin of the durable on-disk format.
const wrapper = await generatePreparedWrapper('config-only-fixture')
const output = await smoke({
label: 'dsh personal repository Plugin',
tempDirPrefix: 'dsh-personal-repository-plugin-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
personal: {
'config.yaml': [
'- id: repository-plugins',
" name: '@deepseek-ai/dsh-repository-plugin'",
' config:',
' repositories:',
` - '${source}'`,
'',
].join('\n'),
[`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`,
[`${packageRoot}/dsh-plugin.mjs`]: wrapper,
[`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [
'---',
'name: config-only-repository',
'description: CONFIG_ONLY_REPOSITORY_SKILL',
'---',
'',
'Repository instructions.',
'',
].join('\n'),
},
}),
actions: [
{ waitFor: 'main-session-', send: '/skill:config-only' },
{ waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' },
],
})
expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
@@ -394,7 +757,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expectedExitCode: 1,
})
expect(output).toContain('must be a top-level YAML array of loader patch entries')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => {
// The flag path end to end: apps/cli parses `--resume missing-session`,
@@ -409,7 +772,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expectedExitCode: 1,
})
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('prints the launcher-owned resume command on exit, naming the booted config', async () => {
// The exit line is built by apps/cli from this invocation, so it must carry
@@ -422,7 +785,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }],
})
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => {
// Loader patches replace a targeted `config` key wholesale, so a personal
@@ -443,7 +806,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
' config:',
' agents:',
' - id: main',
' provider: deepseek',
' provider: deepseek-official',
' model: deepseek-v4-flash',
' cwd: !!js process.cwd()',
'- id: tui',
@@ -457,7 +820,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }],
})
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('reports a failing bash command exactly once, as the terminal card exit pill', async () => {
// The model-facing result ends in `[exit code: 3]`, which the terminal card
@@ -480,13 +843,14 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('SCRIPTED_BASH_FAILED')
expect(output).toContain('[exit 3]')
expect(output).not.toContain('[exit code: 3]')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('tells the model its source path and offers the bundled maintenance skills', async () => {
it('distinguishes its source path from the current workdir and offers the bundled maintenance skills', async () => {
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
// this test file sits an equal depth under the same root, so the same hop applies.
// The source-path line is a system-prompt section; the bundled skills reach the
// model through a durable user message, so each assertion targets its own field.
// The source-path line explicitly distinguishes that checkout from the current workdir;
// bundled skills reach the model through a durable user message, so each assertion
// targets its own field.
const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
let context: LoggedRequestContext = { system: '', skillCatalog: '' }
await smoke({
@@ -502,9 +866,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
],
inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) },
})
expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`)
expect(context.system).toContain(`The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.")
expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.')
expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -7,12 +7,17 @@ import { Context } from 'cordis'
import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
import CommandService from '@deepseek-ai/dsh-commands'
import * as CommandCompact from '@deepseek-ai/dsh-command-compact'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
@@ -36,7 +41,7 @@ import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-termin
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
// Keep pre-normalization layout widths identical across macOS and Linux.
const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp'
const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
const PROVIDERS = [{ id: 'deepseek-official', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
type SnapshotMode = 'replay' | 'record' | 'refresh'
@@ -45,6 +50,8 @@ type ScenarioInteraction = 'skill-invocation-policy'
interface Scenario {
name: string
/** Replay fixture owned by an earlier scenario, for a derived presentation case. */
fixture?: string
composition: Composition
expectedTools: string[]
expectedEventCounts?: Record<string, number>
@@ -53,6 +60,10 @@ interface Scenario {
leavePlanModeAfterFirstTurn?: boolean
recorded: boolean
seedWorkspace?: boolean
/** Add the launcher's model-visible DSH source checkout at this fixed path. */
harnessSourceRoot?: string
/** Replace the real `pwd` result with a portable fixed-length workspace path. */
normalizePwdResult?: boolean
/**
* Load the opt-in `todo_write` tool for this scenario. The shipped TUI
* config omits it, so only the todo-plan scenario (the enabled-path proof)
@@ -68,6 +79,13 @@ interface Scenario {
spillMaxInlineBytes?: number
/** Run scenario-specific terminal input instead of replaying recorded user prompts. */
interaction?: ScenarioInteraction
/**
* Mount a deterministic compaction backend plus `/compact`, then run the
* human command with a held summary while a prompt and injected context
* arrive. Proves queued input waits for the standalone bracket's durability
* checkpoint instead of racing the replacement.
*/
manualCompact?: boolean
}
const SCENARIOS: Scenario[] = [
@@ -80,6 +98,14 @@ const SCENARIOS: Scenario[] = [
leavePlanModeAfterFirstTurn: true,
recorded: true,
},
{
name: 'queued-manual-compact',
fixture: 'multi-turn-conversation',
composition: 'native',
expectedTools: [],
recorded: false,
manualCompact: true,
},
{
name: 'todo-plan',
composition: 'native',
@@ -94,6 +120,14 @@ const SCENARIOS: Scenario[] = [
expectedTools: ['bash'],
recorded: true,
},
{
name: 'source-checkout-workdir',
composition: 'native',
expectedTools: ['bash'],
recorded: true,
harnessSourceRoot: '/opt/dsh-source',
normalizePwdResult: true,
},
{
name: 'parallel-file-reads',
composition: 'native',
@@ -149,6 +183,44 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
const observedScenarios = new Set<string>()
const workerState = Reflect.get(globalThis, '__vitest_worker__') as
| { readonly config?: { readonly testNamePattern?: RegExp } }
| undefined
// Worker argv omits the parent CLI's `-t`; the serialized runner config is the
// authoritative distinction between a focused replay and the full suite.
const TEST_NAME_FILTERED = workerState?.config?.testNamePattern !== undefined
/**
* Deterministic keyless summary that pauses so the scenario can submit a real
* prompt and inject context while manual compaction holds turn admission.
*/
class DeferredSnapshotCompactService extends BasicCompactService {
readonly summaryStarted = Promise.withResolvers<undefined>()
readonly releaseSummary = Promise.withResolvers<undefined>()
override async summarize(
_input: SummarizationInput,
_agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: [{ type: 'text'; text: string }]; provider: string; model: string }> {
this.summaryStarted.resolve(undefined)
await this.releaseSummary.promise
signal?.throwIfAborted()
return {
summary: [{ type: 'text', text: 'Keyless manual compaction checkpoint.' }],
provider: 'snapshot',
model: 'snapshot-compactor',
}
}
}
/** Seed between-turn model-visible history without inventing a loop execution. */
function seedCompactableHistory(agent: Agent): void {
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'Older snapshot context. '.repeat(60) }],
source: { kind: 'plugin', plugin: 'snapshot-seed' },
}))
}
function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
const rel = relative(cwd, displayPath)
@@ -161,10 +233,15 @@ function scenarioDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.name)
}
/** Directory owning the replay fixture: the scenario's own, or the one it derives from. */
function fixtureDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.fixture ?? scenario.name)
}
function childFixturePaths(scenario: Scenario): string[] {
return Array.from(
{ length: scenario.childSessions ?? 0 },
(_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`),
(_, index) => join(fixtureDir(scenario), `session.${index + 1}.jsonl`),
)
}
@@ -187,6 +264,12 @@ function rawSessionLog(session: Session): string {
].join('\n')
}
async function materializeFixtureCwd(fixtureFile: string, cwd: string, replayRoot: string): Promise<string> {
const realized = join(replayRoot, basename(fixtureFile))
await writeFile(realized, (await readFile(fixtureFile, 'utf8')).split('{{cwd}}').join(cwd))
return realized
}
function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
return snapshot
.split(`/private${cwd}`).join('/workspace/project')
@@ -206,12 +289,31 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms')
}
/** Bound deterministic in-process coordination waits with actionable state. */
async function snapshotDeadline<T>(
operation: Promise<T>,
detail: () => string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => { reject(new Error(detail())) }, 5_000)
}),
])
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
async function mountScenarioContext(
scenario: Scenario,
cwd: string,
displayCwd: string,
fixtureFile: string,
childFiles: string[],
replayRoot: string | undefined,
): Promise<Context> {
class SnapshotLocalFileSystem extends LocalFileSystem {
override async resolve(
@@ -231,7 +333,11 @@ async function mountScenarioContext(
tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' },
skills: { local: { agentsHome: join(cwd, '.agents') } },
})
if (scenario.harnessSourceRoot !== undefined) addHarnessSourceSection(ctx, scenario.harnessSourceRoot)
await ctx.plugin(TokenMeterService)
if (scenario.manualCompact === true) {
await ctx.plugin(DeferredSnapshotCompactService, { auto: false })
}
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
@@ -249,6 +355,7 @@ async function mountScenarioContext(
await ctx.plugin(ToolWorkflow)
await ctx.plugin(ToolRalph)
await ctx.plugin(CommandService)
if (scenario.manualCompact === true) await ctx.plugin(CommandCompact)
if (scenario.enterPlanMode === true) {
await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
}
@@ -263,7 +370,12 @@ async function mountScenarioContext(
if (MODE === 'record' && scenario.recorded) {
await ctx.plugin(LlmDeepSeek)
} else {
installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS })
if (replayRoot === undefined) throw new Error('replay mode requires an isolated fixture directory')
// Recorded model text may name the generated cwd. Realize the portable token
// outside that cwd so tools see only the scenario workspace during replay.
const replayFile = await materializeFixtureCwd(fixtureFile, cwd, replayRoot)
const replayChildFiles = await Promise.all(childFiles.map(file => materializeFixtureCwd(file, cwd, replayRoot)))
installLlmReplay(ctx, { file: replayFile, childFiles: replayChildFiles, providers: PROVIDERS })
}
return ctx
}
@@ -276,9 +388,9 @@ interface ScenarioResult {
}
async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime())
const dir = scenarioDir(scenario)
const fixtureFile = join(dir, 'session.jsonl')
const snapshotTime = new Date(2026, 6, 21, 12, 0, 0).getTime()
const clock = vi.spyOn(Date, 'now').mockReturnValue(snapshotTime)
const fixtureFile = join(fixtureDir(scenario), 'session.jsonl')
const childFiles = childFixturePaths(scenario)
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
if (scenario.interaction === undefined) {
@@ -287,15 +399,27 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
const displayCwd = `/tmp/${basename(cwd)}`
let replayRoot: string | undefined
let ctx: Context | undefined
let controller: ReturnType<typeof createTuiChat> | undefined
const terminal = new HeadlessTerminal(100, 36)
try {
if (!(MODE === 'record' && scenario.recorded)) {
replayRoot = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-replay-${scenario.name}-`))
}
if (scenario.seedWorkspace === true) {
const source = join(scenarioDir(scenario), 'workspace')
const source = join(fixtureDir(scenario), 'workspace')
await cp(source, cwd, { recursive: true })
}
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot)
if (scenario.normalizePwdResult === true) {
ctx.on('tools/post-execute', async (exec, result, next) => {
const args = exec.arguments as { command?: unknown }
return exec.name === 'bash' && args.command === 'pwd' && !result.isError
? { kind: 'accept', content: [{ type: 'text', text: '/workspace/project\n' }] }
: next()
})
}
const disposedSessions: Session[] = []
ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
const workflowEvents: string[] = []
@@ -305,9 +429,10 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const handle = await ctx.agents.create({
sessionId: SessionId('main-session'),
meta: { cwd },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
const agent: Agent = handle.agent
if (scenario.manualCompact === true) seedCompactableHistory(agent)
controller = createTuiChat(ctx, {
sessionId: 'main-session',
theme: { color: true },
@@ -380,6 +505,14 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
}
let remainingPrompts = prompts
let queuedPrompt: string | undefined
let manualOrder: string[] | undefined
let manualCommandId: string | undefined
if (scenario.manualCompact === true) {
expect(prompts.length, 'queued manual compaction needs a second replayed prompt').toBeGreaterThanOrEqual(2)
queuedPrompt = prompts.at(-1)
remainingPrompts = prompts.slice(0, -1)
}
if (scenario.enterPlanMode === true) {
const firstPrompt = prompts[0]!
terminal.send(`/plan ${firstPrompt}`)
@@ -396,16 +529,101 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
}
for (const prompt of remainingPrompts) {
const admitted = agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind === 'user').length
terminal.send(prompt)
terminal.send('\r')
await terminal.flush()
await expect.poll(() => agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind === 'user').length).toBe(admitted + 1)
await agent.whenIdle()
await settleTerminal(terminal)
}
if (scenario.manualCompact === true && queuedPrompt !== undefined) {
terminal.send('/help')
terminal.send('\r')
await settleTerminal(terminal)
expect(await terminal.snapshot({ includeScrollback: true }))
.toContain('/compact — Compact older conversation history')
const compact = ctx.compact as DeferredSnapshotCompactService
const inbox: string[] = []
manualOrder = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`)
})
ctx.on('agent/inbox/dequeue', (subject, message) => {
if (subject === agent) inbox.push(`dequeue:${message.id}`)
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'command/run' && event.data.name === 'compact') {
manualCommandId = event.data.commandId
manualOrder?.push('command/run')
}
if (event.type === 'command/done' && event.data.commandId === manualCommandId) {
manualOrder?.push('command/done')
}
if (event.type.startsWith('compact/')) manualOrder?.push(event.type)
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'compact') manualOrder?.push('checkpoint')
if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`)
})
ctx.on('session/flush', (session) => {
if (session === agent.session) manualOrder?.push('flush')
})
terminal.send('/compact')
terminal.send('\r')
await terminal.flush()
await snapshotDeadline(compact.summaryStarted.promise, () =>
`manual summary did not start; status=${agent.status}; tail=${
agent.session.events.slice(-8).map(event => event.type).join(',')
}`)
clock.mockReturnValue(snapshotTime + 1_000)
await settleTerminal(terminal)
await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙')
await expect.poll(() => terminal.snapshot()).toContain('Context being compacted 1.0s')
const liveCompaction = await terminal.snapshot()
expect(liveCompaction.indexOf('Context being compacted 1.0s')).toBeLessThan(liveCompaction.indexOf('dsh ⊙'))
clock.mockReturnValue(snapshotTime)
// Real keystrokes: the prompt keeps its ordinary queue identity while
// admission is reserved, and an injection appends immediately.
terminal.send(queuedPrompt)
terminal.send('\r')
await terminal.flush()
await expect.poll(() => inbox.length).toBe(1)
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'Injected while compaction was running.' }],
source: { kind: 'plugin', plugin: 'snapshot-injector' },
}))
expect(inbox[0]).toMatch(/^enqueue:queued:/u)
expect(agent.status).toBe('idle')
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text === queuedPrompt))).toBe(false)
const idle = agent.whenIdle()
compact.releaseSummary.resolve(undefined)
await snapshotDeadline(idle, () =>
`manual compaction did not reach idle; status=${agent.status}; order=${manualOrder?.join(',') ?? ''}; tail=${
agent.session.events.slice(-12).map(event => event.type).join(',')
}`)
await settleTerminal(terminal)
expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`])
}
const events: SessionEvent[] = [...agent.session.events]
const firstHeader = events.find(event => event.type === 'request/header')
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
.toContain(FILE_REFERENCE_PROMPT)
if (scenario.harnessSourceRoot !== undefined) {
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
.toContain(`The DeepSeek Harness implementation checkout is at ${scenario.harnessSourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
}
expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools)
for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
@@ -437,6 +655,87 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
.toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
}
if (scenario.manualCompact === true) {
const compactStart = events.find(event => event.type === 'compact/start')
const compactSummary = events.find(event => event.type === 'compact/summary')
const compactCheckpoint = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'compact')
const injectedEvent = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'snapshot-injector')
const compactEnd = events.find(event => event.type === 'compact/end')
expect(compactStart?.data.turn).toBeNull()
expect(compactEnd?.data.turn).toBeNull()
expect(events.filter(event => event.type === 'compact/summary')).toHaveLength(1)
if (compactStart === undefined || compactSummary === undefined
|| compactCheckpoint === undefined || injectedEvent === undefined
|| compactEnd === undefined) {
throw new Error('manual compaction snapshot is missing its durable marker, summary, checkpoint, or injection')
}
// The markers are time points, not an exclusive container: unrelated
// idle injection is allowed between them while the selected span stays stable.
expect(compactStart.seq).toBeLessThan(injectedEvent.seq)
expect(injectedEvent.seq).toBeLessThan(compactSummary.seq)
expect(compactSummary.seq).toBeLessThan(compactCheckpoint.seq)
expect(compactCheckpoint.seq).toBeLessThan(compactEnd.seq)
const manualTimeline = manualOrder ?? []
const commandRunIndex = manualTimeline.indexOf('command/run')
const compactStartIndex = manualTimeline.indexOf('compact/start')
const compactEndIndex = manualTimeline.indexOf('compact/end')
const firstFlushIndex = manualTimeline.indexOf('flush')
const queuedTurnIndex = manualTimeline.indexOf('turn/start:message')
const commandDoneIndex = manualTimeline.indexOf('command/done')
expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1)
expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1)
expect(compactStartIndex).toBeGreaterThan(commandRunIndex)
expect(compactEndIndex).toBeGreaterThan(compactStartIndex)
expect(firstFlushIndex).toBeGreaterThan(compactEndIndex)
expect(queuedTurnIndex).toBeGreaterThan(firstFlushIndex)
expect(commandDoneIndex).toBeGreaterThan(firstFlushIndex)
const commandRun = events.find(event => event.type === 'command/run'
&& event.data.name === 'compact')
const commandRunId = commandRun?.type === 'command/run'
? commandRun.data.commandId
: undefined
const commandDone = events.find(event => event.type === 'command/done'
&& event.data.commandId === commandRunId)
expect(commandRun?.type === 'command/run' && commandRun.data).toEqual({
commandId: commandRunId,
name: 'compact',
args: '',
source: { kind: 'user' },
})
expect(commandDone?.type === 'command/done' && commandDone.data).toEqual({
commandId: commandRunId,
kind: 'success',
text: 'Compacted 2 history items (~387 tokens).',
})
expect(commandRun !== undefined && commandRun.seq < compactStart.seq).toBe(true)
expect(commandDone !== undefined && commandDone.seq > compactEnd.seq).toBe(true)
expect(agent.session.surface.nodes).not.toContain(commandRun?.seq)
expect(agent.session.surface.nodes).not.toContain(commandDone?.seq)
// The command line itself never becomes a prompt.
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text.trim() === '/compact'))).toBe(false)
const derived = agent.session.deriveMessages().map(message => message.content
.map(block => block.type === 'text' ? block.text : '')
.join(''))
const checkpoint = derived.findIndex(text => text.includes('Keyless manual compaction checkpoint.'))
const injected = derived.findIndex(text => text.includes('Injected while compaction was running.'))
const queued = derived.findIndex(text => text === queuedPrompt)
expect(checkpoint).toBe(0)
expect(injected).toBeGreaterThan(checkpoint)
expect(queued).toBeGreaterThan(injected)
expect(derived).not.toContain('/compact')
expect(derived).not.toContain('Compacted 2 history items (~387 tokens).')
expect(derived.filter(text => text.includes('Injected while compaction was running.'))).toHaveLength(1)
expect(compactSummary.data.shadowedSeqs).not.toContain(injectedEvent.seq)
const queuedTurn = events.findLast(event => event.type === 'turn/start')
expect(queuedTurn !== undefined && compactEnd.seq < queuedTurn.seq).toBe(true)
}
if (scenario.spillMaxInlineBytes !== undefined) {
// The REAL pipeline ran (tools execute on replay too): the durable
// dispatch copy is bounded to a preview + locator under the run cwd,
@@ -477,6 +776,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
await ctx?.fiber.dispose()
await terminal.dispose()
await rm(cwd, { recursive: true, force: true })
if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
clock.mockRestore()
}
}
@@ -514,7 +814,23 @@ describe('TUI recorded-session terminal snapshots', () => {
})
afterAll(async () => {
expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
const scenarioNames = SCENARIOS.map(scenario => scenario.name).sort()
const observedNames = [...observedScenarios].sort()
if (TEST_NAME_FILTERED) {
expect(observedNames).not.toHaveLength(0)
expect(scenarioNames).toEqual(expect.arrayContaining(observedNames))
} else {
expect(observedNames).toEqual(scenarioNames)
}
for (const [index, scenario] of SCENARIOS.entries()) {
if (scenario.fixture === undefined) continue
const sourceIndex = SCENARIOS.findIndex(candidate => candidate.name === scenario.fixture)
expect(sourceIndex, `${scenario.name} fixture source ${scenario.fixture} must exist`).toBeGreaterThanOrEqual(0)
expect(sourceIndex, `${scenario.name} fixture source must precede it`).toBeLessThan(index)
const source = SCENARIOS[sourceIndex]
expect(source?.fixture, `${scenario.name} fixture source must own its replay files`).toBeUndefined()
expect(source?.recorded, `${scenario.name} fixture source must be recordable`).toBe(true)
}
const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true }))
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
@@ -522,14 +838,14 @@ afterAll(async () => {
expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
for (const scenario of SCENARIOS) {
const expected = [
'session.jsonl',
...scenario.fixture === undefined ? ['session.jsonl'] : [],
'terminal.expected.txt',
...scenario.seedWorkspace === true ? ['workspace'] : [],
...scenario.seedWorkspace === true && scenario.fixture === undefined ? ['workspace'] : [],
...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`),
].sort()
expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected)
for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) {
const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8')
const content = await readFile(join(fixtureDir(scenario), fixture), 'utf8')
expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content)
}
}

View File

@@ -0,0 +1,32 @@
import { sep } from 'node:path'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { prepareWebRuntimeContext } from '../src/web.ts'
describe('prepareWebRuntimeContext', () => {
it('installs both sections before a later systemPrompt consumer activates', async () => {
const ctx = new Context()
const sourceRoot = `${sep}opt${sep}harness-src`
let observedSections: { name: string; text: string }[] | undefined
try {
prepareWebRuntimeContext(ctx, sourceRoot, 'production')
ctx.provide('httpServer', { port: 3080 } as Context['httpServer'])
const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => {
const assembly = await promptCtx.systemPrompt.assemble()
observedSections = assembly.sections
})
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
await consumer
expect(observedSections?.map(section => section.name)).toContain(HARNESS_SOURCE_SECTION)
expect(observedSections?.find(section => section.name === 'app:web-surface')?.text)
.toContain('http://127.0.0.1:3080')
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../packages/ui/app-boot"
},
{
"path": "../../packages/bash/tool-bash"
},
{
"path": "../../packages/ui/tui"
},

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>DeepSeek Harness</title>
</head>
<body>

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50.000000" height="50.000000" viewBox="0 0 50 50" fill="none">
<path id="path" d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000" fill-opacity="1.000000" fill-rule="nonzero"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,101 @@
// Web e2e scenario: every visible permission picker gates Full access behind
// the same locale-aware, in-page risk confirmation. Zero model calls: the
// scenario boots the shipped Web composition and exercises the real
// permission projection, client command path, HTTP RPC, and pushed update.
import { mkdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
/**
* connectFreshWorkspace twin over the product default Chinese locale (the
* shared helper's anchors assume the English page every other scenario
* boots; this scenario deliberately keeps zh, so the localized picker
* copy is the anchor set).
*/
async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: '打开', exact: true }).click()
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
.waitFor({ timeout: 15_000 })
}
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: Full access confirmation', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// CI uses Playwright's pinned browser. A developer may point this one
// scenario at an installed Chromium when the matching browser download
// is temporarily unavailable.
const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
// Keep the product default Chinese locale: the golden pins the actual
// registered dictionary rather than a test-local translation callback.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('requires acknowledgement before the composer picker can enable Full access', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
const access = page.locator('button[aria-label^="访问模式"]').first()
await access.waitFor({ timeout: 10_000 })
expect(await access.getAttribute('aria-label')).toBe('访问模式当前Workspace Write')
await access.click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用 Full access' })
await dialog.waitFor({ timeout: 10_000 })
const enable = dialog.getByRole('button', { name: '启用 Full access' })
expect(await enable.isDisabled()).toBe(true)
// The modal is in this page's body (not a native/new window) and escapes
// the sticky composer's stacking context.
expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
expect(await enable.isEnabled()).toBe(true)
await enable.click()
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('访问模式当前Full access')
expect(await dialog.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -0,0 +1,181 @@
// Web e2e scenario: the composer-takeover approval panel under a long
// command. The shipped composition confines bash through the sandbox policy
// and routes its escalation through the approval seam, so a read-only session
// asked to write a file produces a REAL pending approval — the panel renders
// in the browser, the test measures its geometry, answers through it, and the
// escalated command then runs. Replay is deterministic: the denial, the
// escalation retry and its command text arrive from replayed chunks, and the
// answer click is the test's own gesture (the same sanctioned reaction to
// model content as the question composer: the turn cannot complete without it).
//
// Geometry is the point of the scenario. The command is unbounded model text,
// and before the cap a long one grew the card until the refuse/allow buttons
// left the viewport — an approval the user could see and not answer.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
// Empty type import: carries the approval package's session-event merge, so
// the decided-outcome assertion below type-checks against the real union.
import type {} from '@deepseek-ai/dsh-user-approval'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// The scenario's one golden: the waiting panel. Everything the answered state
// proves is asserted directly — see the world-state block at the end.
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
// Irreducible payload: the command has to be long enough to pass the card's
// height cap, which is the only shape that reproduces an action row pushed off
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
// short command proves nothing here. The formula keeps the source small; the
// model receives the expanded literal it has to put in the command.
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
const PROMPT = `Write a file named notes.txt in the workspace containing exactly this text on one line: ${TOKENS}. Use one bash command with the literal text inline. Then reply with the single word DONE and stop.`
/** Draft used to measure the composer's own text cap: enough lines to pass it. */
const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
describe('web e2e: approval takeover keeps its actions reachable', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('caps the long command, answers through the panel, and runs the escalated command', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-approval'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
// The composer's own text cap, measured on the live textarea before the
// takeover replaces it. The panel's scroll region must stop at the same
// height (the designer's requirement: one cap for the composer seat), and
// measuring it here keeps the assertion free of the px value itself.
await input.fill(CAP_PROBE)
const composerCap = await input.evaluate(el => el.clientHeight)
expect(composerCap).toBeGreaterThan(0)
await input.fill('')
// Read-only: the mode whose denial the model escalates from. Switched
// through the shipped access-mode chip, not a test-only seam.
await page.locator('[aria-label^="Access mode"]').click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await expect.poll(
() => page.locator('[aria-label="Access mode, current: Read Only"]').count(),
{ timeout: 15_000 },
).toBe(1)
const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000)
await input.fill(PROMPT)
await input.press('Enter')
// The panel takes over the input area while the tool blocks. Its presence
// is a STABLE waiting state (it stays until answered), so waitFor is
// race-free.
const panel = page.locator('[data-approval-key]')
await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
const scroll = panel.locator('[data-approval-scroll]')
await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
if (MODE !== 'record') {
// This golden owns the stable waiting surface; the answered golden below
// owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
// The regression this scenario exists for: an uncapped card grew with
// the command until the action row left the viewport. Measured at the
// lane baseline and at a short viewport, on the live panel.
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
for (const height of [1000, 700]) {
await page.setViewportSize({ width: 900, height })
const geometry = await panel.evaluate((root) => {
const region = root.querySelector<HTMLElement>('[data-approval-scroll]')
const card = region?.parentElement ?? null
// Role/text, not the CSS-module class names: the built client hashes those.
const buttons = [...root.querySelectorAll<HTMLElement>('button')]
const rows = buttons.map(button => button.getBoundingClientRect())
return {
buttons: buttons.length,
capped: region === null ? 0 : region.clientHeight,
// A scrolling region proves the cap is genuinely engaged; without
// it every assertion below would hold vacuously.
scrolls: region === null ? false : region.scrollHeight > region.clientHeight,
cardBottom: card === null ? Number.NaN : card.getBoundingClientRect().bottom,
actionsTop: Math.min(...rows.map(rect => rect.top)),
actionsBottom: Math.max(...rows.map(rect => rect.bottom)),
viewport: window.innerHeight,
}
})
expect(geometry.buttons).toBe(2)
expect(geometry.scrolls).toBe(true)
// One cap for the seat: the panel's text region stops where the
// composer draft does (sub-pixel tolerance for the shared padding).
expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1)
// Both buttons stay inside the card AND inside the viewport — the
// answerable state the cap exists to guarantee.
expect(geometry.actionsTop).toBeGreaterThan(0)
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport)
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom)
}
await page.setViewportSize(original)
}
await panel.getByRole('button', { name: 'Allow once' }).click()
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
return
}
// World state: the granted escalation is what let the command run, and the
// panel leaves with the regular composer restored. Asserted on the world
// and the DOM rather than through a transcript golden — the denied first
// attempt renders the OS's own refusal ("Operation not permitted" on
// macOS, "Read-only file system" on Linux), so the answered transcript is
// not a platform-neutral golden surface.
expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1)))
.toContain('allowed-once')
const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8')
expect(written).toContain(TOKENS.slice(0, 64))
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-approval-key]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 300_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
})
})

View File

@@ -0,0 +1,82 @@
// Web e2e scenario: a cancelled Bash call can settle without terminal-card
// material. Borrow the real cancellation fixture and prove the keyed Bash row
// still exposes the recorded command and full error without any model call.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'bash-abort-row-web-e2e'
const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.'
describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
const fixture = await readFile(FIXTURE, 'utf8')
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
scaffold = await launchWebScaffold({})
await seedSession(scaffold, fixture, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('expands the aborted row to its command and full error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row'))
const row = page.locator('[data-sample="bash"]').first()
const call = row.locator('xpath=..')
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
await call.getByText('IN', { exact: true }).waitFor()
await call.getByText('OUT', { exact: true }).waitFor()
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
// The borrowed fixture's UTC date is still the previous day in PDT;
// the disclosure golden must not depend on the runner timezone.
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -60,6 +60,9 @@ let unmount: (() => void) | undefined
beforeEach(() => {
localStorage.clear()
// English pinned before boot: role/text locators stay deterministic across
// localized component migrations (the newEnglishPage e2e convention).
localStorage.setItem('dsh.locale', 'en')
document.title = 'DeepSeek Harness'
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
@@ -105,7 +108,45 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
// The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/
// write row first. The write turn's `hello fixture\n` proves the terminator
// rule end to end: a trailing newline terminates its line, so the footer reads
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
// ::before, so it is absent from textContent — assert on the line body and the
// footer.
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
expect(mutationRows.length).toBeGreaterThan(0)
for (const row of mutationRows) {
const toggle = row.querySelector('[data-expandable]')
if (toggle !== null) act(() => { fireEvent.click(toggle) })
}
const diffCards = [...document.querySelectorAll('[data-diff]')]
expect(diffCards.length).toBeGreaterThan(0)
const footers = diffCards.map(card => card.textContent ?? '')
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
// The web render intent reaches the assembled boot graph: the fixture's
// web_search / web_fetch turns render their keyed WebRow cards, proving the
// registration, wire projection, and card rendering survive the real bundle
// path (not just the per-package src benches). WebRow composes ToolRow, so the
// card is collapsed behind the row; the keyed row is pinned by its `data-tool`
// (ToolRow sets it from the wire tool name).
const webSearchRow = await waitFor(() => {
const row = document.querySelector('[data-tool="web_search"]')
expect(row).not.toBeNull()
expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
return row!
}, { timeout: 10_000 })
// Expand the web_search row to prove its WebBlock card renders end to end.
const webToggle = webSearchRow.querySelector('[data-expandable]')
if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
await waitFor(() => {
expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
}, { timeout: 10_000 })
// Every bundle injected its plugin-owned style tag (the loader's CSS path).

View File

@@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
@@ -112,7 +112,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
// the bash sub-call landed in the bash sample registration.
const nest = page.locator('[data-subcalls]').first()
await nest.waitFor({ timeout: 10_000 })
expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1)
expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1)
// The failing read sub-call wears the same error state a native failed
// row wears (the recorded program tolerates a read of missing.txt).
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
@@ -123,7 +123,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
const nest = page.locator('[data-subcalls]').first()
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
await nest.locator('[data-sample="bash-global"]').first().click()
await nest.locator('[data-sample="bash"]').first().click()
// Tool rows do not drive layout geometry; the Session's default panel stays closed.
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
})

View File

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

View File

@@ -66,7 +66,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
@@ -107,7 +107,8 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
await mountRow.waitFor({ timeout: 10_000 })
await mountRow.locator('button[aria-expanded]').click()
// The whole summary row is the expand toggle (unified tool-row interaction).
await mountRow.locator('[aria-expanded]').first().click()
await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
.toContain(MOUNT_CODE)

View File

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

View File

@@ -3,15 +3,19 @@
// unselected states, and closes it only when a different Session takes ownership.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url))
const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -25,11 +29,41 @@ async function detailsTrack(page: Page): Promise<number> {
})
}
/** First AppFrame grid track in CSS pixels. */
async function sidebarTrack(page: Page): Promise<number> {
return await appFrame(page).evaluate((element) => {
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
return Number.parseFloat(tracks[0] ?? 'NaN')
})
}
/** AppFrame is the only product element with an inline grid track template. */
function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first()
}
/** Render the two boundary affordances without platform-dependent coordinates. */
async function handleSnapshot(page: Page): Promise<string> {
const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
elements.map(element => ({
side: element.getAttribute('data-side'),
cursor: getComputedStyle(element).cursor,
pillGenerated: getComputedStyle(element, '::after').content !== 'none',
})))
return [
'# AppFrame drag handles',
'',
...handles.flatMap(handle => [
`## ${handle.side}`,
'',
'- hit strip present: true',
`- cursor: ${handle.cursor}`,
`- pill generated: ${String(handle.pillGenerated)}`,
'',
]),
].join('\n').trimEnd()
}
describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -46,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await appFrame(page).waitFor({ timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
@@ -64,7 +98,19 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE)
const sidebarBefore = await sidebarTrack(page)
const sidebarHandle = page.locator('[data-side="sidebar"]')
const sidebarBox = await sidebarHandle.boundingBox()
expect(sidebarBox).not.toBeNull()
const dragStartX = sidebarBox!.x + sidebarBox!.width / 2
await page.mouse.move(dragStartX, sidebarBox!.y + 200)
await page.mouse.down()
await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 })
await page.mouse.up()
await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
@@ -72,18 +118,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await appFrame(page).waitFor({ timeout: 30_000 })
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first()
await original.click()
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
const ungrouped = page.getByText('Ungrouped', { exact: true })
const ungroupedRow = ungrouped.locator('..').locator('..')
@@ -101,5 +147,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
}, 90_000)
})

View File

@@ -0,0 +1,71 @@
// Keyless assembled-browser coverage for the goal bar over the shipped Web
// bundles and FixtureApiClient wire. The command creates a real projected
// goal in the fixture session; the golden pins the active strip, while the
// clear gesture proves the acknowledged tombstone leaves neither stale chrome
// nor a duplicate-mutation error.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-bar', import.meta.url))
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: goal bar clear convergence', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('renders one active goal and clears it without exposing a stale error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
// Startup reuses the fixture workspace's blank session, keeping this
// command independent of alpha's running replay and pending question.
const input = page.getByPlaceholder('Describe what you want to build')
await input.waitFor({ timeout: 10_000 })
await input.fill('/goal guard rapid clear clicks')
await input.press('Enter')
const bar = page.locator('[data-goal-bar]')
await bar.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
const clear = bar.getByRole('button', { name: 'Clear goal' })
await clear.evaluate((button) => {
const control = button as HTMLButtonElement
control.click()
control.click()
})
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
expect(await page.getByText(/no current goal/iu).count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md'])
})
})

View File

@@ -0,0 +1,5 @@
# The client-side FixtureApiClient intentionally rejects settings writes, so
# this goal-only scenario omits the durable welcome step that would otherwise
# cover the page. Onboarding owns separate assembled-browser coverage.
- id: ui-settings-general
disabled: true

View File

@@ -0,0 +1,132 @@
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { REPO_ROOT } from './support.ts'
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
return {
argv,
cwd,
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
graceMs: 5_000,
...env === undefined ? {} : { env },
}
}
function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
return new Promise((resolveReady, reject) => {
let output = ''
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.stdout?.off('data', onData)
child.stderr?.off('data', onData)
}
const resolveOnce = (value: string): void => {
if (settled) return
settled = true
cleanup()
resolveReady(value)
}
const rejectOnce = (error: Error): void => {
if (settled) return
settled = true
cleanup()
reject(error)
}
const onData = (chunk: Buffer): void => {
output += chunk.toString()
const match = pattern.exec(output)
if (match === null) return
resolveOnce(match[1] ?? match[0])
}
const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
child.stdout?.on('data', onData)
child.stderr?.on('data', onData)
void child.done.then((outcome) => {
rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
}, (error: unknown) => {
rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
})
})
}
async function stopTree(child: SubprocessHandle): Promise<void> {
child.terminate()
const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
await child.done
}
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
const oldText = "Let's start building"
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
const subprocessCtx = new Context()
let subprocessFiber: Fiber | undefined
let watcher: SubprocessHandle | undefined
let host: SubprocessHandle | undefined
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
const failures: unknown[] = []
try {
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
world,
{
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
DSH_HOME: join(world, '.dsh'),
},
))
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
browser = await chromium.launch()
const page = await browser.newPage()
const pageErrors: string[] = []
page.on('pageerror', error => pageErrors.push(String(error)))
await page.goto(baseUrl, { waitUntil: 'load' })
await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
const pageIdentity = await page.evaluate(() => {
const identity = crypto.randomUUID()
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
return identity
})
await writeFile(sourcePath, updatedSource)
await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
.toBe(pageIdentity)
expect(pageErrors).toEqual([])
} catch (error) {
failures.push(error)
} finally {
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
await browser?.close().catch((error: unknown) => failures.push(error))
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
}, 120_000)

View File

@@ -25,6 +25,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim.
const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
@@ -48,7 +50,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
@@ -56,6 +58,88 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
const launcher = page.getByRole('button', { name: 'Commands' })
await launcher.click()
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('text: Commands')
expect(snapshot).not.toContain('text: Skills')
expect(snapshot).not.toContain('text: Subagents')
const launchedBox = await menu.boundingBox()
await page.locator('textarea').first().press('Escape')
await expect.poll(() => menu.count()).toBe(0)
const input = page.locator('textarea').first()
await input.fill('/')
await menu.waitFor({ timeout: 10_000 })
const typedBox = await menu.boundingBox()
expect(launchedBox).not.toBeNull()
expect(typedBox).not.toBeNull()
expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
expect(Math.abs(
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
)).toBeLessThan(1)
await input.fill('')
await expect.poll(() => menu.count()).toBe(0)
})
it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
const activeScaffold = await launchWebScaffold()
const activePage = await newEnglishPage(browser)
const activeTripwire = watchConsole(activePage)
try {
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
const input = activePage.locator('textarea').first()
await activePage.getByRole('button', { name: 'Commands' }).click()
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
await expect.poll(() => input.inputValue()).toBe('/plan ')
await input.press('Enter')
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
const planStyle = await planButton.evaluate((element) => {
const probe = document.createElement('span')
probe.style.color = 'var(--dsw-alias-state-warn-label)'
probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
document.body.append(probe)
const actual = getComputedStyle(element)
const reference = getComputedStyle(probe)
const result = {
color: actual.color,
backgroundColor: actual.backgroundColor,
borderRadius: actual.borderRadius,
fontSize: actual.fontSize,
referenceColor: reference.color,
referenceBackgroundColor: reference.backgroundColor,
}
probe.remove()
return result
})
expect(planStyle.color).toBe(planStyle.referenceColor)
expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
expect(planStyle.borderRadius).toBe('999px')
expect(planStyle.fontSize).toBe('13px')
await planButton.click()
await expect.poll(() => planButton.count()).toBe(0)
expect(activeTripwire.pageErrors).toEqual([])
expect(activeTripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
throw error
} finally {
await activePage.close()
await activeScaffold.close()
}
})
it('sends the first prompt from the empty-state hero (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
if (MODE !== 'record') {
@@ -74,8 +158,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const observeTurn = async () => {
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
try {
await input.press('Enter')
if (MODE !== 'record') {
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
await expect.poll(async () => await liveTail.evaluate(element => (
element.scrollWidth > element.clientWidth
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
)), { timeout: 10_000, interval: 10 }).toBe(true)
}
return await settled
} finally {
if (MODE !== 'record') await page.setViewportSize(originalViewport)
}
}
const sessionId = await observeTurn()
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
@@ -88,10 +188,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Host: the session's durable header cwd is the workspace flow's
// create-by-name target (<workspaceRoot>/workspace, the composer's
// default draft name) — the proof the send went through workspace
// materialization rather than a bare default-cwd session.
// Host: the session's durable header cwd is the folder the workspace
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
// send went through workspace materialization rather than a bare
// default-cwd session.
const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
@@ -152,6 +252,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
])
})
})

View File

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

View File

@@ -8,6 +8,7 @@ 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 { SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
@@ -19,6 +20,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import
// a new recording (workspace-management / sidebar-scrollbar pattern).
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'message-actions-web-e2e'
@@ -62,14 +64,14 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). User has three actions; each finalized assistant
// text node has copy + branch.
const copyButtons = page.getByRole('button', { name: '复制' })
// hover/focus-within). User and each turn's last content assistant both
// have copy + branch.
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await copyButtons.first().focus()
await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 })
await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
}, 60_000)
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
@@ -79,15 +81,64 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
}).waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
await page.getByRole('button', { name: '复制' }).first().focus()
await page.getByRole('button', { name: 'Copy' }).first().focus()
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
// Exercise the assistant action specifically; package coverage pins the
// user action separately at its own event seq.
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),
{ timeout: 15_000 },
).toBeDefined()
await expect.poll(
() => page.locator('[role="treeitem"]').count(),
{ timeout: 10_000 },
).toBe(3)
await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 10_000 },
).toBe(1)
// The row action owns a distinct ui-workspace injection from the message
// action above, so exercise both through the loaded app before capture.
const sourceRow = page.locator('[role="treeitem"][aria-selected="true"]')
const rowBox = await sourceRow.boundingBox()
if (rowBox === null) throw new Error('fork source row has no layout box')
const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]')
await sourceRow.hover({ position: { x: rowBox.width - 16, y: rowBox.height / 2 } })
await expect.poll(() => actionButton.isVisible(), { timeout: 2_000 }).toBe(true)
const buttonBox = await actionButton.boundingBox()
if (buttonBox === null) throw new Error('fork source row action has no layout box')
await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2)
await page.getByRole('menuitem', { name: 'Fork session' }).click()
await expect.poll(
() => scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession !== undefined).length,
{ timeout: 15_000 },
).toBe(2)
await expect.poll(
() => page.locator('[role="treeitem"]').count(),
{ timeout: 10_000 },
).toBe(4)
await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 10_000 },
).toBe(1)
const tree = await captureStableAria(
page,
'[role="tree"][aria-label="Sessions"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE)
})
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['fork.expected.md', 'ui.expected.md'])
})
})

View File

@@ -0,0 +1,152 @@
// Web e2e scenario: the Models settings page end to end through the real
// wire — the add card offers the dormant pi-ai catalog, typing an API key
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
// while the settings document records only that reference; the saved row
// appears after the route topology invalidation without presenting liveness
// as provider status. The customized-settings fold writes the curated
// reasoning field as a merge patch. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud on the open seam. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
// never shadow the derived reference. Removing that row is guarded by the
// localized provider-confirmation dialog before the unset reaches the wire.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: Models settings page configures a dormant provider', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('opens the add card over the dormant directory vocabulary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
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 dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
// The dormant pi-ai adapter contributes its whole installed catalog; no
// provider is configured yet, so the page is one add button.
const add = dialog.getByRole('button', { name: '+ 添加提供方' })
await add.waitFor({ timeout: 10_000 })
// The button enables once the dormant catalog lands in the join.
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
const pick = dialog.getByLabel('提供方')
await pick.waitFor({ timeout: 10_000 })
await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
const options = await pick.locator('option').allTextContents()
expect(options).toContain('anthropic')
expect(options).toContain('minimax-cn')
await pick.selectOption('minimax-cn')
await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
}, 60_000)
it('stores the key under the derived reference and the route registers live', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The profile lands in settings.yaml with only the derived reference, the
// key value lands in the harness home's .env, the dormant route
// registers, and the topology frame invalidates the page into the row.
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
expect(document).not.toContain('sk-e2e-minimax')
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
expect(await page.content()).not.toContain('sk-e2e-minimax')
}, 60_000)
it('applies a customized-settings field as a merge patch', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑' }).click()
await dialog.getByText('自定义设置').click()
const effort = dialog.getByLabel('推理强度')
await effort.waitFor({ timeout: 10_000 })
await effort.selectOption('high')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The editor closes back to the row; the fold's write merged into the
// stored profile beside the reference.
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('reasoning: high')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('confirms provider deletion before removing its settings profile', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
await deleteDialog.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="删除模型提供方?"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
await page.getByRole('dialog', { name: '删除模型提供方?' })
.getByRole('button', { name: '删除提供方', exact: true }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
.toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
await expect.poll(
async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
{ timeout: 10_000 },
).toBe(0)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
})
})

View File

@@ -23,6 +23,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
@@ -42,7 +43,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace;
// the read targets must live in that session cwd (pre-creation is safe:
// create-by-name adopts an existing directory).
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
@@ -66,6 +67,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
// the ready projection replaces.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterAll(async () => {
@@ -95,44 +100,61 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
}, 400_000)
it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
// Expand the collapsed group row, then open the revealed session row.
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
await search.fill('zzzqx-no-such-session')
await page.getByText('No matching sessions').waitFor({ timeout: 30_000 })
await expect.poll(
() => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(),
{ timeout: 10_000 },
).toBe(0)
await search.fill('WATERFALL')
const resultTree = page.getByRole('tree', { name: 'Search results' })
const result = resultTree.getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
timeout: 10_000,
}).toBeGreaterThanOrEqual(1)
const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
await result.click()
// Search navigation addresses the session, not a specific event, and the
// query remains until the user explicitly clears it.
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
}, 90_000)
it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// Runs after the session is open: a cold summary carries no title (the
// sidebar shows the cwd basename), and the durable title lands with the
// attach subscription's baseline — which is itself worth pinning: search
// matches the title the user sees, not a hidden cold field.
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Negative: a garbage query empties the tree (group rows hide too).
await search.fill('zzzqx-no-such-session')
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
// Positive: a title word narrows to the matched session + its group,
// force-expanded by search mode (case-insensitive client-side filter).
await search.fill('navscenario')
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
// Clear restores the unfiltered tree.
await page.getByRole('button', { name: 'Clear search' }).click()
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
}, 60_000)
}, 90_000)
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await page.getByRole('tab', { name: 'Trajectory' }).click()
await page.waitForTimeout(100)
const overlayLayout = await page.getByRole('table').evaluate((table) => {
const host = table.closest('[data-conversation-scroll]')
const seat = host?.querySelector('[data-composer-seat]') ?? null
const pane = table.parentElement
return {
hostPosition: host === null ? null : getComputedStyle(host).position,
paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX,
paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth,
seatPosition: seat === null ? null : getComputedStyle(seat).position,
}
})
expect(overlayLayout).toEqual({
hostPosition: 'relative',
paneOverflowX: 'hidden',
paneScrollableWidth: 0,
seatPosition: 'absolute',
})
expect({
pageErrors: tripwire.pageErrors,
slotErrors,
@@ -146,14 +168,34 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
await page.locator('tr[data-kind="tool"]').first().click()
await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
const details = page.getByRole('complementary', { name: 'Event details' })
await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX))
.toBe('hidden')
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
heading: getComputedStyle(heading).backgroundColor,
panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor,
}))
expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await page.getByRole('tab', { name: 'Result' }).click()
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
await assistantSpan.hover()
const timingTooltip = page.getByRole('tooltip')
await timingTooltip.waitFor({ timeout: 5_000 })
await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
const assistantTimingStyle = await assistantSpan.evaluate(node => ({
background: getComputedStyle(node).backgroundImage,
ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
}))
expect(assistantTimingStyle.background).toContain('linear-gradient')
expect(assistantTimingStyle.ttft).toMatch(/%$/)
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
await page.getByRole('complementary', { name: 'Event details' })
.getByRole('button', { name: 'Close details' }).click()
await details.getByRole('button', { name: 'Close details' }).click()
}, 60_000)
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
@@ -176,15 +218,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click()
const bashRow = page.locator('[data-sample="bash-global"]').first()
const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 })
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
// The row click is the card's expand toggle (unified tool-row
// interaction); it must not drive layout geometry either way.
await bashRow.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// The card's own controls are outside the summary row and must not open
// details either — the terminal card is read in place.
await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
// details either — the expanded terminal card is read in place.
await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// Read summaries are host-open file links; they also must not open details.
const fileLink = page.locator('[data-variant="read"] button').first()
@@ -196,10 +240,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
await page.getByRole('tab', { name: 'Chat' }).click()
// The card is resident in the keyed bash row (no expand gesture): the
// recorded command's own output sits in the message flow, derived from the
// logged call/result presentations alone.
const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
// The card is expand-gated behind the whole-row toggle (the unified
// tool-row interaction): open it if a previous case left it collapsed.
// Expanded, the recorded command's own output sits in the message flow,
// derived from the logged call/result presentations alone.
const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 })
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
await card.waitFor({ timeout: 15_000 })
// Real layout, not jsdom's stub (which computes no geometry at all):
// squeeze the output pane below its content width and the line must keep
@@ -253,7 +301,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
}
})
expect(dot.state).toBe('done')
expect(dot.label).toBe('已完成')
expect(dot.label).toBe('Done')
expect(dot.beforePrompt).toBe(true)
expect(dot.insideCard).toBe(true)
expect(dot.leftOfPrompt).toBe(true)
@@ -270,7 +318,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await card.locator('[class*="_copyButton_"]').first().click()
await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
.toBe('复制成功')
.toBe('Copied')
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
}, 60_000)
@@ -279,7 +327,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(slotErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md',
])
})
})

View File

@@ -0,0 +1,166 @@
// Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
// credential is absent, onboarding routes to the real Models editor, and its
// write lands in an isolated harness home without a reload or model call.
import { randomBytes } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const browserConsole: string[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
page.on('console', message => browserConsole.push(message.text()))
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('stores a key write-only and observes configured state without restarting', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
expect(await welcome.locator('button').count()).toBe(1)
const mask = page.locator('[class*="onboardingMask"]')
expect(await mask.count()).toBe(1)
const maskStyles = await mask.evaluate((mask) => {
const style = getComputedStyle(mask)
const rect = mask.getBoundingClientRect()
return {
position: style.position,
left: style.left,
right: style.right,
top: style.top,
bottom: style.bottom,
background: style.backgroundColor,
backdropFilter: style.backdropFilter,
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
}
})
expect(maskStyles).toEqual({
position: 'absolute',
left: '0px',
right: '0px',
top: '80px',
bottom: '0px',
background: 'rgba(0, 0, 0, 0.24)',
backdropFilter: 'blur(2px)',
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
})
// Closing the process/page before acknowledgement writes nothing, so the
// same durable profile presents the notice again after reload.
const firstReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
await credentialStep.waitFor({ timeout: 15_000 })
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
await credentialStep.getByRole('button', { name: '前往配置' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
const keyInput = settings.getByLabel('API 密钥', { exact: true })
await keyInput.waitFor({ timeout: 10_000 })
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
await keyInput.fill(secret)
await settings.getByRole('button', { name: '保存', exact: true }).click()
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
expect((await page.content()).includes(secret)).toBe(false)
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
// The same open Models surface reuses the refreshed join and exposes the
// configured write-only placeholder without a reload.
const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
await deepSeekRow.waitFor({ timeout: 10_000 })
await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
const configuredInput = settings.getByLabel('API 密钥', { exact: true })
await configuredInput.waitFor({ timeout: 10_000 })
await expect.poll(
() => configuredInput.getAttribute('placeholder'),
{ timeout: 10_000 },
).toBe('已配置——输入新值可替换')
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
const secondReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
// A different stored copy version represents an intentional version bump:
// the welcome step returns even though the credential is already ready.
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
}])
const thirdReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
expect((await page.content()).includes(secret)).toBe(false)
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
})
})

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
// Web e2e scenario: the plan-review takeover. The shipped composition mounts
// plan mode and its client seat, so `/plan <task>` enters plan mode for real
// and the recorded turn ends on exit_plan_mode blocking against the live
// userInteraction seam. The composer is then occupied by the plan decision
// card — not the generic question flow — and approving it through the card
// completes the turn with the approval in the log.
// Replay is deterministic: the plan content arrives from replayed chunks, the
// review wait is real, and the approve click is the test's own gesture (the
// turn cannot complete without it, in record and replay alike).
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plan-review', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// The waiting golden owns the decision card; the approved golden owns the
// transcript the approval leaves behind — the state the card cannot see.
const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
const MODE = webSnapshotMode()
// One command line: /plan enters plan mode and submits the rest as the turn's
// message. The task is deliberately self-contained (nothing to explore in a
// fresh workspace) so the recorded turn is a plan and its review, and the
// approved continuation is one word.
const TASK = 'Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. '
+ 'Call exit_plan_mode with a short plan of at most five bullet points. '
+ 'Once the plan is approved, reply with the single word DONE and stop.'
const LINE = `/plan ${TASK}`
describe('web e2e: plan review takeover round trip', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
// English page: the decision copy is the surface under test, and the
// golden pins one language.
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('reviews the plan on a decision card and approves through it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
await input.fill(LINE)
await input.press('Enter')
// The card takes over the input area while exit_plan_mode blocks. Its
// presence is a STABLE waiting state (it stays until answered), so a plain
// waitFor is race-free.
const card = page.locator('[data-plan-review-key]')
await card.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
// The plan-review request must NOT land on the generic question flow.
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE)
}
await card.getByRole('button', { name: 'Approve' }).click()
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
return
}
// World state: the approval reached the tool, and plan mode is left behind.
const results = sessionEvents.filter(e => e.type === 'tool/result')
expect(JSON.stringify(results.at(-1))).toContain('Plan approved')
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Card gone; regular input restored.
expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md'])
})
})

View File

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

View File

@@ -1,16 +1,16 @@
// Keyless browser coverage for pending queue actions through the shipped Web
// composition and real HTTP/SSE wire. A replay override parks the active turn
// so two ordinary follow-ups remain addressable while the page edits one and
// removes one. The queue uses an existing recorded model
// call; this scenario owns only the user-visible mid-turn golden.
// composition and real HTTP/SSE wire. Replay overrides park consecutive turns
// so the page can edit and remove exact occurrences, then stop the active turn
// while proving the preserved Queue advances in FIFO order.
import { existsSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -20,7 +20,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
@@ -28,6 +31,12 @@ const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing,
const REMOVE = 'Queue item to remove'
const EDIT = 'Queue item to edit'
const EDITED = 'Edited queue item'
const TAIL = 'Queue item preserved after stop'
/** Durable turn-end classifications observed by the scenario. */
function turnEndReasons(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])
}
describe('web e2e: queue row actions', () => {
let scaffold: WebScaffold | undefined
@@ -51,13 +60,19 @@ describe('web e2e: queue row actions', () => {
if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed')
})
it.skipIf(MODE === 'record')('edits and removes exact pending occurrences', async () => {
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
const readyFile = join(overrideDir, '.hang-ready')
const nextReadyFile = join(overrideDir, '.next-hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
await writeFile(overridePath, JSON.stringify({
patches: [{ at: 0, entry: { kind: 'hang', readyFile } }],
}))
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
expect(recorded).toHaveLength(1)
const replay: ReplayEntry[] = [
{ kind: 'hang', readyFile },
{ kind: 'hang', readyFile: nextReadyFile },
recorded[0]!,
]
await writeFile(overridePath, JSON.stringify(replay))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
@@ -67,7 +82,7 @@ describe('web e2e: queue row actions', () => {
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
const input = page.locator('textarea').first()
@@ -80,38 +95,173 @@ describe('web e2e: queue row actions', () => {
await input.fill(text)
await input.press('Enter')
}
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
const collapsedSnapshot = await captureStableAria(
page,
'[class*="centerCol"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE)
await queueHeader.click()
await expect.poll(
() => page.getByRole('button', { name: '删除排队消息' }).count(),
() => page.getByRole('button', { name: 'Remove queued message' }).count(),
{ timeout: 10_000 },
).toBe(2)
await page.setViewportSize({ width: 640, height: 1000 })
const queueBox = await page.locator('[data-queue-dock]').boundingBox()
const composerBox = await page.locator('[data-composer-card]').boundingBox()
expect(queueBox).not.toBeNull()
expect(composerBox).not.toBeNull()
expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
expect(queueBox!.x + queueBox!.width)
.toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
const queueLeftInset = queueBox!.x - composerBox!.x
const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
const style = getComputedStyle(element)
return {
dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
}
})
expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
await page.setViewportSize({ width: 1680, height: 1000 })
const editRow = page.getByText(EDIT, { exact: true }).locator('..')
await editRow.getByRole('button', { name: '编辑排队消息' }).click()
const editor = page.getByRole('textbox', { name: '编辑排队消息' })
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
await editor.fill(EDITED)
const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE)
await page.getByRole('button', { name: '保存排队消息' }).click()
await page.getByRole('button', { name: 'Save queued message' }).click()
await page.getByText(EDITED, { exact: true }).waitFor()
const removeRow = page.getByText(REMOVE, { exact: true }).locator('..')
await removeRow.getByRole('button', { name: '删除排队消息' }).click()
await removeRow.getByRole('button', { name: 'Remove queued message' }).click()
await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(sessionEvents.filter(event => event.type === 'user/message')).toHaveLength(1)
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
const editedRow = page.getByText(EDITED, { exact: true }).locator('..')
await editedRow.getByRole('button', { name: '删除排队消息' }).click()
await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0)
await input.fill(TAIL)
await input.press('Enter')
await expect.poll(
() => page.getByRole('button', { name: 'Remove queued message' }).count(),
{ timeout: 10_000 },
).toBe(2)
await page.getByRole('button', { name: 'Stop generating' }).click()
await expect.poll(() => existsSync(nextReadyFile), { timeout: 15_000 }).toBe(true)
await page.getByText(TAIL, { exact: true }).waitFor()
await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
.toBe(1)
const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents)).toEqual(['aborted', 'aborted', 'completed'])
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user'))
.toHaveLength(3)
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
}, 120_000)
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
const readyFile = join(overrideDir, '.hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
const input = page.locator('textarea').first()
const settled = scaffold.whenTurnSettled()
await input.fill('/goal Keep the composer context panels aligned')
await input.press('Enter')
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
const sessions = scaffold.ctx.sessions.list()
expect(sessions).toHaveLength(1)
sessions[0]!.append('todo/write', {
todos: [
{ content: 'Confirm the panel order', status: 'completed' },
{ content: 'Align the panel widths', status: 'in_progress' },
],
})
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
for (const text of ['Layout queue first', 'Layout queue second']) {
await input.fill(text)
await input.press('Enter')
}
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
const layoutSnapshot = await captureStableAria(
page,
'[class*="centerCol"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
const expectAlignedContextPanels = async () => {
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
expect(queuePanelBox).not.toBeNull()
expect(todoBox).not.toBeNull()
expect(goalBox).not.toBeNull()
expect(todoBox!.y).toBeLessThan(goalBox!.y)
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
}
await expectAlignedContextPanels()
await page.setViewportSize({ width: 640, height: 1000 })
await expectAlignedContextPanels()
await page.setViewportSize({ width: 1680, height: 1000 })
await queueHeader.click()
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
await removeButtons.first().click()
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
await removeButtons.first().click()
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Clear goal' }).click()
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['editing.expected.md', 'ui.expected.md'])
await assertFixtureInventory(
SNAPSHOT_DIR,
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
)
})
})

View File

@@ -9,20 +9,23 @@
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// The scenario's one drive prompt. Record sends it; replay asserts the
@@ -35,6 +38,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let settledSessionId: SessionId | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
@@ -48,7 +52,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
@@ -69,11 +73,44 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
settledSessionId = sessionId
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 200_000)
it('records the Web surface, source checkout, and session cwd in the request header', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
const system = agent.session.requestHeader()?.system
if (system === undefined) throw new Error('the settled Web request has no system prompt')
const prefix = system.split('\n\n').slice(0, 4).join('\n\n')
.split(REPO_ROOT).join('{{sourceRoot}}')
.split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}')
.split(scaffold.baseUrl).join('{{webUrl}}')
await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE)
})
it('exposes the assembled Web URL to the real bash tool', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
const result = await scaffold.ctx.tools.execute({
signal: AbortSignal.timeout(5_000),
callId: CallId('web-url-probe'),
name: 'bash',
arguments: {
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
description: 'Print current Web runtime',
},
agent,
})
expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe(`${scaffold.baseUrl}\nproduction\n`)
})
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
// Browser settled-poll after host completion (host strictly precedes render).
@@ -129,6 +166,6 @@ describe('web e2e: fresh round trip through the real assembly', () => {
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md'])
})
})

View File

@@ -0,0 +1,57 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type {} from '@deepseek-ai/dsh-skill'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
async function writeSkill(root: string, name: string): Promise<void> {
const bundle = join(root, name)
await mkdir(bundle, { recursive: true })
await writeFile(join(bundle, 'SKILL.md'), `---
name: ${name}
description: Must not enter the Web replay scaffold
---
Ambient host state.
`)
}
it('isolates replay skill discovery from every ambient host root', async () => {
const ambient = await mkdtemp(join(tmpdir(), 'dsh-web-ambient-skills-'))
const dshHome = join(ambient, 'dsh-home')
const agentsHome = join(ambient, 'agents-home')
const bundled = join(ambient, 'bundled')
await Promise.all([
writeSkill(join(dshHome, 'skills'), 'ambient-dsh'),
writeSkill(join(agentsHome, 'skills'), 'ambient-agents'),
writeSkill(bundled, 'ambient-bundled'),
])
const originalDshHome = process.env.DSH_HOME
const originalAgentsHome = process.env.DSH_AGENTS_HOME
const originalBundled = process.env.DSH_BUNDLED_SKILL_DIR
process.env.DSH_HOME = dshHome
process.env.DSH_AGENTS_HOME = agentsHome
process.env.DSH_BUNDLED_SKILL_DIR = bundled
let scaffold: WebScaffold | undefined
try {
scaffold = await launchWebScaffold()
const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
expect(names).not.toContain('ambient-dsh')
expect(names).not.toContain('ambient-agents')
expect(names).not.toContain('ambient-bundled')
} finally {
try {
await scaffold?.close()
} finally {
if (originalDshHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = originalDshHome
if (originalAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
else process.env.DSH_AGENTS_HOME = originalAgentsHome
if (originalBundled === undefined) delete process.env.DSH_BUNDLED_SKILL_DIR
else process.env.DSH_BUNDLED_SKILL_DIR = originalBundled
await rm(ambient, { recursive: true, force: true })
}
}
})

View File

@@ -4,17 +4,21 @@
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: llm-deepseek row disabled, dsh-llm-replay row
// inserted in providers mode), record (real adapter + key, harvests fixtures
// from live session memory), refresh (keyless replay that rewrites goldens).
// replay (default, keyless: normally disables the llm-deepseek row and
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
// harvests fixtures from live session memory), refresh (keyless replay that
// rewrites goldens). A first-run option keeps the real adapter mounted while
// masking its credential, without making a model call.
//
// Composition divergences from `dsh web`, all deliberate, all via include
// patches after the shipped surface overlay: temp persistenceRoot;
// workspace-context disabled (recorded fixtures must not embed this repo's
// AGENTS.md); session-title-llm disabled (its fire-and-forget title call
// would race the loop for the session's replay cursor); webserver pinned to
// port 0 with the built dist; keyless modes disable llm-deepseek and fill
// the open llm seam post-boot with installLlmReplay on the settled root ctx
// patches after the shipped surface overlay, over the SAME tree (never a
// second yml): temp persistenceRoot; host-level skill roots confined to the
// temp workspace while project skill discovery remains real; workspace-context
// disabled (recorded fixtures must not embed this repo's AGENTS.md);
// session-title-llm disabled (its fire-and-forget title call would race the
// loop for the session's replay cursor); webserver pinned to port 0 with the
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
// llm seam post-boot with installLlmReplay on the settled root ctx
// (the plugin-row path discards the ReplayHandle; the direct install keeps
// assertConsumed for the teardown fixture-consumption check).
import { existsSync } from 'node:fs'
@@ -29,6 +33,11 @@ import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import SessionStore, {
@@ -44,6 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
@@ -70,7 +80,7 @@ const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml')
// post-step pressure check would warn every step). The published
// contextWindow keeps that pressure path provably inert for small fixtures.
const REPLAY_PROVIDERS = [{
id: 'deepseek',
id: 'deepseek-official',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
}]
@@ -87,6 +97,8 @@ export interface WebScaffold {
workspaceCwd: string
/** Temp persistence root (seeded sessions land here through the real API). */
persistenceRoot: string
/** Isolated harness home the settings/credentials rows write ($DSH_HOME double). */
harnessHome: string
/** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
whenTurnSettled(timeoutMs?: number): Promise<SessionId>
/** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */
@@ -95,6 +107,12 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/**
* Optional product overlay applied after the shipped Web surface and before
* the scaffold's hermetic test patches, matching AppCLIEntry's `--config`
* ordering.
*/
extraOverlayPath?: string
/**
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
* in replay/refresh modes; ignored in record mode (the real adapter
@@ -103,6 +121,11 @@ export interface LaunchOptions {
* mounts).
*/
replayFixture?: string
/**
* Recorded child logs assigned in child creation order. Each child owns its
* own positional replay cursor across initial and continuation turns.
*/
replayChildFixtures?: string[]
/**
* Optional replay.override.json sidecar (whole-script replacement or
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
@@ -124,6 +147,25 @@ export interface LaunchOptions {
* remain reconstructable without making the tools a product default.
*/
cordisTools?: boolean
/**
* Keep the shipped DeepSeek adapter mounted while masking the process
* environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/**
* Patch the shipped DeepSeek search row to a deterministic endpoint and
* credential reference. Browser search scenarios keep the real provider and
* credentials seam while avoiding external search traffic and ambient keys.
*/
deepSeekSearch?: {
/** Anthropic-compatible base URL; the provider appends `/messages`. */
baseURL: string
/** Credential reference resolved by the shipped search provider. */
apiKeyEnv: string
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -150,7 +192,26 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
}
}
if (mode === 'record' && options.deepSeekMissingCredential === true) {
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
}
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
let credentialEnvironmentRestored = false
const restoreCredentialEnvironment = (): void => {
if (credentialEnvironmentRestored || !maskDeepSeekCredential) return
credentialEnvironmentRestored = true
if (originalDeepSeekCredential === undefined) {
Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
} else {
process.env.DEEPSEEK_API_KEY = originalDeepSeekCredential
}
}
const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
let persistenceRoot: string
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
@@ -160,29 +221,73 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
// The include patch set — the same mechanism AppCLIEntry and the ACP
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
// stops matching a row fails the boot sweep loudly instead of drifting).
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_OVERLAY_PATH)
const extraOverlayPatches = options.extraOverlayPath === undefined
? []
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
const patches: PatchOptions[] = [
...surfacePatches,
...extraOverlayPatches,
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
// storage-json's './.storages' yml default is cwd-relative and resolves
// per write; the scaffold restores the original cwd after boot, so the
// row gets an absolute temp root (removed with the workspace at close).
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
// to an absolute temp root (removed with the workspace at close) so tests
// never write the user's harness home.
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
// Skill discovery is model-visible input. Pin every host-level root inside
// the owned temp world so ~/.dsh, ~/.agents, and a bundled-root env setting
// cannot change replay requests or conversation goldens. Project roots stay
// enabled against the same empty temp workspace, preserving the real seam.
{
id: 'skill-local',
config: {
dshHome: join(workspaceCwd, '.dsh-home'),
agentsHome: join(workspaceCwd, '.agents-home'),
bundledSkillDir: join(workspaceCwd, '.bundled-skills'),
watch: false,
},
},
// fs/bash cwd default to process.cwd(); the gateway injects the same
// value into session.cwd — chdir below anchors all three to the temp
// workspace, keeping the composition untouched.
{ id: 'workspace-context', disabled: true },
{ id: 'session-title-llm', disabled: true },
// Fixture sessions must never leave the process: the shipped row defaults
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
{ id: 'settings', config: { dshHome: harnessHome } },
{ id: 'credentials', config: { dshHome: harnessHome } },
// The shipped directory-picker row is the -auto chooser, which resolves
// the interaction from the RUNNING host (display, SSH launch, bind). The
// lane's goldens are interaction-specific (workspace-management drives
// the in-app browse dialog), so pin -browse deterministically on every
// host: patch `name` is an assertion, not an override, hence the
// disable+insert pair.
{ id: 'directory-picker', disabled: true },
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
: [],
...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }],
...options.deepSeekSearch === undefined
? []
: [{
id: 'web-search-deepseek',
config: {
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
baseURL: options.deepSeekSearch.baseURL,
},
}],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'llm-deepseek', disabled: true }],
]
// Sessions inherit the gateway's process.cwd() default; run the boot from
@@ -194,38 +299,48 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
try {
process.chdir(workspaceCwd)
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
// This direct Loader harness supplies the same root-path capability as app-boot.
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
// The shipped CLI deliberately has no dependency on this opt-in package.
// Keep the Loader row real without broadening the product installation.
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
})
await ctx.loader.await()
assertEntriesLoaded(ctx, 'web e2e scaffold')
if (options.welcomeNoticePending !== true) {
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
}])
}
const boundPort = ctx.get('httpServer')?.port
if (boundPort === undefined) {
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
}
port = boundPort
// Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
// in keyless modes; a scenario with no fixture leaves the seam empty so a
// stray stream fails loud with NO_ADAPTER). The direct install, unlike the
// plugin row, returns the ReplayHandle for the teardown consumption check.
// Fill the open llm seam on the settled root ctx. Ordinary keyless modes
// disable llm-deepseek; the first-run lane keeps it mounted but has no
// replay fixture and never streams. The direct install, unlike the plugin
// row, returns the ReplayHandle for the teardown consumption check.
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}
} catch (error) {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
restoreCredentialEnvironment()
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
}
@@ -235,6 +350,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
return {
harnessHome,
mode,
baseUrl: `http://127.0.0.1:${port}`,
ctx,
@@ -273,7 +389,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} catch (error) {
failures.push(error)
}
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
try {
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
} finally {
restoreCredentialEnvironment()
}
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
},
}
@@ -384,18 +504,26 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
* volatility collapse to stable tokens.
*/
function normalizeAria(snapshot: string, workspaceCwd: string): string {
// The header breadcrumb renders the workspace's basename, not the full
// The session heading renders the workspace's basename, not the full
// path, so both spellings must collapse to the token.
const base = workspaceCwd.split('/').pop()!
return snapshot
.split(workspaceCwd).join('{{cwd}}')
.split(base).join('{{workspace}}')
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
.replace(
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
duration => duration.startsWith('~') ? duration : '{{duration}}',
)
.replace(
/约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g,
duration => duration.startsWith('约') ? duration : '{{duration}}',
)
// Message IconActions clocks widen by calendar day/year; collapse every
// shape so goldens stay stable across midnight and year boundaries.
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
}

View File

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

View File

@@ -1,9 +1,12 @@
// Web e2e scenario: seeded history. A recorded session seeded cold through
// the REAL persistence API renders purely from the log — the surface nothing
// else covers: sidebar cold listing, the implicit resume/attach inside the
// history RPC, history-page tool views, and the client fold of historical
// history RPC, history-page tool views, and the client's log-ordered transcript
// events — with ZERO model calls in replay (no replay fixture; a stray stream
// fails loud on the open llm seam). The seed is a recorded fixture under the
// fails loud on the open llm seam). The cold session also carries the one
// keyless command-row surface: an Access-chip pick runs `/permission` on the
// host, so the settled row's copy has a golden here. The seed is a recorded
// fixture under the
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// live through the composer (real read tool against seeded workspace files)
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
@@ -24,11 +27,98 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url))
// The command-row golden: the same conversation after one /permission switch,
// which is the only surface that shows a settled command row's copy.
const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'seeded-history-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
/**
* Append a complete, valid compaction transaction over the recorded turn's own
* surface. The recording stays model-authentic and reusable; replay adds this
* deterministic condition before seeding it cold, so the scenario pins the bug
* this change fixes — a landed compaction must not erase history the reader
* already saw — through the real host and the real browser.
* @param raw - the committed seed fixture text.
* @returns the fixture with a compacted turn appended.
*/
function withCompaction(raw: string): string {
const lines = raw.trimEnd().split('\n')
const events = lines.slice(1).map(line => JSON.parse(line) as {
type: string
seq: number
time: number
surfaceOp?: unknown
data?: { turn?: unknown }
})
const surfaceSeqs = events
.filter(event => event.surfaceOp === 'append'
&& (event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'))
.map(event => event.seq)
const first = surfaceSeqs[0]
const last = surfaceSeqs.at(-1)
const tail = events.at(-1)
if (first === undefined || last === undefined || tail === undefined) {
throw new Error('seeded-history compaction requires a non-empty closed surface')
}
// The transaction opens the turn after the recording's last closed one; read
// it from the fixture so a re-recording with a different turn count stays
// valid instead of appending a duplicate turn number.
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
if (typeof lastTurn !== 'number') {
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
}
const turn = lastTurn + 1
let seq = tail.seq + 1
let time = tail.time + 1
/**
* Append one event at the next seq/time.
* @param event - the event body, without seq/time.
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
*/
const at = (event: Record<string, unknown>): number => {
const taken = seq++
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
const startSeq = at({ type: 'compact/start', data: { turn } })
const summarySeq = at({
type: 'compact/summary',
data: {
summary: [{
type: 'text',
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
}],
shadowedRange: { start: first, end: last },
shadowedSeqs: surfaceSeqs,
shadowedTokenCount: 10_000,
provider: 'snapshot',
model: 'snapshot-compactor',
},
})
at({
type: 'user/message',
data: {
content: [{
type: 'text',
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'compact' },
},
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn } })
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
return `${lines.join('\n')}\n`
}
describe('web e2e: seeded history renders through cold resume', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -37,7 +127,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace
// (the composer's default draft name); the read-tool targets must live in
// that session cwd. Pre-creating the directory is safe: create-by-name
// adopts an existing directory.
@@ -48,7 +138,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(raw), SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -114,11 +204,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
await sessionRow.click()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
// Tool cards render from logged tool/call + tool/result alone (views are
// host-recomputed per page; the generic card is the documented default).
const toolRows = page.locator('[data-variant], [data-sample]')
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
// The bug this fixes: the compaction shadowed the whole recorded surface on
// the model side, and the prompt and full tool output are still on screen.
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
@@ -142,7 +236,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
}],
},
}))
await page.getByRole('button', { name: '上下文注入' }).waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 })
}, 60_000)
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
@@ -160,7 +254,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
const disclosure = page.getByRole('button', { name: '上下文注入' })
const disclosure = page.getByRole('button', { name: 'Context injection' })
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
const collapsedIcon = disclosure.locator('svg').first()
const collapsedIconBox = await collapsedIcon.boundingBox()
@@ -225,11 +319,70 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
})
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
const marker = page.getByRole('button', { name: /Context compacted/ })
await marker.waitFor({ timeout: 10_000 })
expect(await marker.getAttribute('aria-expanded')).toBe('false')
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
timeout: 5_000,
}).toBe(1)
expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
// Restore the shared page state for any later case.
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
})
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no
// model call, so the settled row renders keylessly over this cold history.
// The row copy is the assertion: `permission · preset read-only`,
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
// Scoped to the row itself, so unrelated page text that happens to read
// `permission` (a future resident slash menu) cannot satisfy or break it.
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('fits short injected context without a scrollport', async () => {
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'Short injected context.' }],
source: { kind: 'plugin', plugin: 'fixture' },
}))
const disclosures = page.getByRole('button', { name: 'Context injection' })
await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2)
const disclosure = disclosures.nth(1)
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const body = page.locator('[data-context-injection-body]')
const bodyBox = await body.boundingBox()
if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
expect(bodyBox.height).toBeLessThan(141)
expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
// No replay fixture was installed and the llm seam is open — any stray
// stream would have failed the turn loudly. Cleanliness pins the wire.
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md'])
})
})

View File

@@ -2,26 +2,30 @@
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// and the Language row (settings-scoped localization + persisted dsh.locale).
// the Language row (settings-scoped localization + persisted dsh.locale),
// the busy-state Enter preference, plus Permission as the persisted default
// for subsequently created sessions.
// Zero model calls: everything is pure client + persistence state on a blank
// frame, so there is no fixture and a stray stream would fail loud on the
// open llm seam.
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 { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: settings modal, appearance gesture, language switch', () => {
describe('web e2e: settings modal and General preferences', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -30,7 +34,9 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
// Chinese browser: the shared page asserts the localized settings surface
// the client derives from it (the English default has its own spec below).
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -50,15 +56,15 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
// General is the active section by default; its skeleton rows plus the
// functional Language and Appearance rows render.
// General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
// Golden of the freshly opened dialog (default zh, General active).
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
// Section switch: aria-current moves; Models is deliberately empty.
// Section switch: aria-current moves (the Models page itself has its own scenario file).
await dialog.getByRole('button', { name: '模型' }).click()
await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
@@ -73,6 +79,55 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stores Permission as the default for future sessions without changing an existing session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'workspace-write' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
const selector = dialog.getByRole('button', { name: 'Workspace Write' })
await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('permission:')
expect(document).toContain('defaultPreset: read-only')
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'workspace-write' })
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
expect(created.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'read-only' }],
['sandbox/mode', { mode: 'read-only' }],
['approval/policy', { policy: 'ask' }],
])
await dialog.getByRole('button', { name: 'Read Only' }).click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const confirmation = page.getByRole('dialog', { name: '确认启用 Full access' })
const enable = confirmation.getByRole('button', { name: '启用 Full access' })
expect(await enable.isDisabled()).toBe(true)
await confirmation.getByRole('checkbox').click()
await enable.click()
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('flips the theme through the Appearance cubes and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
@@ -128,6 +183,32 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('persists the busy-state Enter behavior across reload and restores Queue', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '排队发送' }).click()
await page.getByRole('menuitem', { name: '插话发送' }).click()
await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer')
await page.keyboard.press('Escape')
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '设置', exact: true }).click()
const reloaded = page.getByRole('dialog', { name: '设置' })
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
await reloaded.getByRole('button', { name: '插话发送' }).click()
await page.getByRole('menuitem', { name: '排队发送' }).click()
await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue')
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('switches the settings surface language and persists dsh.locale', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -163,6 +244,30 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('opens an English browser in English without any stored preference', async () => {
// A second page under a different browser language: nothing is persisted
// for it, so the settings surface must follow the browser rather than the
// product fallback the shared zh page shows.
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
const enTripwire = watchConsole(enPage)
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
try {
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
// This page has no closing inventory spec to sweep its console, so the
// scenario clears both tripwire channels itself.
expect(enTripwire.pageErrors).toEqual([])
expect(enTripwire.warnings).toEqual([])
} finally {
await enPage.close()
}
}, 90_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])

View File

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

View File

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

View File

@@ -20,12 +20,14 @@ import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
let out = ''
@@ -163,6 +165,8 @@ describe('dsh web keyless CLI smoke', () => {
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-no-call',
DSH_HOME: join(sessionsDir, '.dsh'),
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -182,14 +186,18 @@ describe('dsh web keyless CLI smoke', () => {
}
})
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
interface NativeProviderRequest {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
}
let resolveProviderRequest!: (request: NativeProviderRequest) => void
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
resolveProviderRequest = resolve
})
const provider = createServer((request, response) => {
@@ -197,7 +205,7 @@ describe('dsh web keyless CLI smoke', () => {
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
@@ -214,7 +222,7 @@ describe('dsh web keyless CLI smoke', () => {
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
{
cwd: workspace,
env: {
@@ -222,6 +230,7 @@ describe('dsh web keyless CLI smoke', () => {
DEEPSEEK_API_KEY: 'keyless-web-workspace',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_HOME: join(workspace, '.dsh'),
DSH_AGENTS_HOME: join(workspace, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -241,8 +250,14 @@ describe('dsh web keyless CLI smoke', () => {
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
}),
])
expect(captured.messages?.some(message =>
message.role === 'user' && message.content?.includes('<available_skills>'))).toBe(false)
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
.replace('{{webUrl}}', baseUrl)
expect(systemMessage?.content).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(`
{
"content": "<system-reminder>
@@ -256,6 +271,13 @@ describe('dsh web keyless CLI smoke', () => {
"role": "user",
}
`)
expect(captured.tools?.map(tool => tool.function?.name)
.filter(name => name === 'web_search' || name === 'web_fetch'))
.toMatchInlineSnapshot(`
[
"web_search",
]
`)
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
@@ -267,6 +289,98 @@ describe('dsh web keyless CLI smoke', () => {
}
})
it('retries a partial transport failure through the shipped Web composition', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
const promptMarker = 'WEB_RETRY_REQUEST'
const recoveredMarker = 'WEB_RETRY_RECOVERED'
let mainAttempts = 0
const provider = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
const titleRequest = parsed.max_tokens === 64
const mainRequest = !titleRequest && body.includes(promptMarker)
response.writeHead(200, { 'content-type': 'text/event-stream' })
if (!mainRequest) {
response.end([
'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
return
}
mainAttempts++
if (mainAttempts === 1) {
response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
setTimeout(() => { response.destroy() }, 20)
return
}
response.end([
`data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
})
})
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
const address = provider.address()
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
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'],
{
cwd: workspace,
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-retry',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_HOME: join(workspace, '.dsh'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
try {
const baseUrl = await waitForReadyLine(child)
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: promptMarker }],
})
let page: HistoryPage | undefined
await expect.poll(async () => {
page = await history(baseUrl, created.sessionId)
return hasAssistantMarker(page, recoveredMarker)
}, { timeout: 20_000 }).toBe(true)
if (page === undefined) throw new Error('retry history was not observed')
const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
expect(mainAttempts).toBe(2)
expect(retry?.data).toMatchObject({
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
failure: { code: 'TRANSPORT' },
})
expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
: Promise.resolve()
if (child.exitCode === null) child.kill('SIGTERM')
await closed
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
rmSync(workspace, { recursive: true, force: true })
}
}, 30_000)
it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
@@ -310,6 +424,7 @@ describe('dsh web keyless CLI smoke', () => {
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_TOOLS_MODE: 'code',
DSH_HOME: join(workspace, '.dsh'),
DSH_AGENTS_HOME: join(workspace, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -358,17 +473,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
const port = await probeFreePort()
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
// the global Harness home inside the temp world; tsx also needs the repo's
// loader and tsconfig paths pointed at explicitly.
// the host-level Harness and shared-agent homes inside the temp world; tsx
// also needs the repo's loader and tsconfig paths pointed at explicitly.
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
[
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
// Pin the in-browser picker: the shipped `-auto` row would resolve to
// the native OS chooser on this bind, and no page can drive that.
'--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
],
{
cwd: sessionsDir,
env: {
...process.env,
DSH_HOME: join(sessionsDir, '.dsh'),
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -403,8 +524,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
it('2+3 empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
// This scenario spawns its own server against a fresh $DSH_HOME, so the
// first-run welcome notice is unacknowledged and its overlay owns pointer
// events (the shared scaffold acknowledges it before boot instead). The
// notice is anchored structurally, not by its copy: this spec sits in the
// client TypeScript program, which does not reference the package that
// owns the strings.
const welcome = page.locator('[class*="onboardingOverlay"]')
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button').click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
// Fresh world: connect a Workspace so the composer starts live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, sessionsDir)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')
@@ -463,7 +594,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
// Bash renders through the third-party sample registration. Match that
// exact row: other clickable variants (for example Think disclosure)
// may precede the tool call in document order.
const toolRow = page.locator('[data-sample="bash-global"]')
const toolRow = page.locator('[data-sample="bash"]')
await toolRow.waitFor({ timeout: 120_000 })
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0)

View File

@@ -0,0 +1,10 @@
- dialog "确认启用 Full access":
- heading "确认启用 Full access" [level=2]
- button "Close":
- img
- img
- paragraph: 启用 Full access 后agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- checkbox "我已了解风险,并愿意继续"
- text: 我已了解风险,并愿意继续
- button "取消"
- button "启用 Full access" [disabled]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
- text: Waiting for approval
- group "Approval details": "escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt"
- button "Reject"
- button "Allow once"

View File

@@ -0,0 +1,32 @@
- banner:
- navigation "Session hierarchy":
- 'button "Run two shell commands: wait" [disabled]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- button "Context injection":
- img
- img
- text: Context injection
- 'button "Failed Bash Error: command aborted" [expanded]':
- img
- text: "Failed Bash Error: command aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted"
- button "Inspect"
- 'button "Failed Bash Error: tool call aborted before dispatch"':
- img
- text: "Failed Bash Error: tool call aborted before dispatch"
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok

View File

@@ -1,9 +1,9 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785013630399,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785013630411,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1c4a6a0f-a7bd-4642-919d-6ed7395df98b"},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785013630418,"data":{"title":"Using ONE run_code program: run","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785013630479,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785013631481,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785013631481,"data":{"turn":1,"step":1,"index":0,"dt":[182,27,0,0,1,39,1,0,11,0,0,1,0,25,0,1,0,0,25,28,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,28,1,0,0,0,23,1,0,1,0,0,25,1,0,0,0,26,0,0,0,26,0,26,0,0,1,25,1,0,0,0,0,25,1,26,0,1,26,29],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]}}
{"type":"assistant/chunk","seq":81,"time":1785013632219,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -12,13 +12,13 @@
{"type":"assistant/chunk","seq":202,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}}
{"type":"assistant/chunk","seq":203,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}}
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0d217af9-3bb3-425b-b53e-7ab0366f0c66"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"content":[{"type":"tool-result","toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false}],"role":"user","id":"1580d4a8-5760-415f-984f-f93927271d3f"}},"sourceEventSeqs":[206],"surfaceOp":"append"}
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":214,"time":1785013633985,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -30,6 +30,6 @@
{"type":"assistant/chunk","seq":233,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":234,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}}}
{"type":"assistant/chunk","seq":235,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3037ec34-6c2a-4e01-91eb-a0ef89ce0d15"}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
{"type":"step/end","seq":237,"time":1785013634225,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":238,"time":1785013634225,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -5,40 +5,43 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
- button "复制":
- button "Copy":
- img
- button "在新对话中分支":
- button "Branch into a new conversation":
- img
- button "编辑":
- button "Context injection":
- img
- img
- text: Context injection
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
- text: "Think The user wants me to write a single `run_code` program that:"
- button:
- button "Code Run bash echo and catch missing file read":
- img
- img
- text: Code Run bash echo and catch missing file read
- text: Code Run bash echo and catch missing file read
- img
- text: Bash Echo CODE_ROUND_OK Read
- button "missing.txt"
- text: Bash Echo CODE_ROUND_OK Failed
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
- img
- text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- img
- text: Think The program ran successfully. Let me now reply DONE as instructed.
- paragraph: DONE
- button "复制":
- button "Copy":
- img
- button "在新对话中分支":
- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 52% Input 17.2K tok · Output 252 tok
- text: 1 turns · 2 steps Tool call {{duration}} Context 7% of 128K Cache hit 52% Input 17.2K tok · Output 252 tok

View File

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

View File

@@ -1,9 +1,9 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"27e1fc72-20d0-4875-bf5b-d34750441f30"},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785157564667,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785157564667,"data":{"turn":1,"step":1,"index":0,"dt":[115,31,3,0,1,0,1,21,1,0,0,0,1,23,0,1,0,0,0,24,2,1,0,0,0,28,2,0,0,0,1,23,2,22,2,1,25,2,0,0,25,27,1,1,0,0,28,2,0,0,0,0,32,1,31,1,0,0,0,9,29,3,0,0,0,1,23,1,0,0,0,27,4,0,0,0,23,2,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Call"," `","cord","is","_in","spect","`"," with"," `","what",":"," \"","t","emporary","\"`\n","2","."," Call"," `","cord","is","_m","ount","`"," with"," the"," exact"," code"," provided","\n","3","."," Read"," the"," returned"," id"," and"," call"," `","cord","is","_un","mount","`"," with"," that"," exact"," id","\n","4","."," Reply"," exactly"," \"","C","ORD","IS","_","UI","_D","ONE","\""," and"," stop","\n\n","Let"," me"," start"," with"," step"," ","1","."]}}
{"type":"assistant/chunk","seq":87,"time":1785157565360,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -12,9 +12,9 @@
{"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}}
{"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}}
{"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a3b56157-c6fd-43f8-bc8a-0e2a0c99a8b5"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
{"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_KZk918WtlKan9pHMULIT8794"},"content":[{"type":"tool-result","toolCallId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false}],"role":"user","id":"95ae124e-f065-4aab-a9c5-bd33c7eafff1"}},"sourceEventSeqs":[104],"surfaceOp":"append"}
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"}
{"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -25,9 +25,9 @@
{"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}}
{"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b90af675-f604-4fb4-9c27-89c91b27f4ce"}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
{"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361"},"content":[{"type":"tool-result","toolCallId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"3148fc0a-f511-4b72-abd5-d184e974cd49"}},"sourceEventSeqs":[161],"surfaceOp":"append"}
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"}
{"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -38,9 +38,9 @@
{"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
{"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}}
{"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdc5b840-4302-486a-8065-fa59bf13f2d9"}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
{"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_e38S6zeYdZGvbhecUCil6659"},"content":[{"type":"tool-result","toolCallId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"758a7b27-56e3-4997-8c4d-effc699cc5e6"}},"sourceEventSeqs":[208],"surfaceOp":"append"}
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"}
{"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -51,6 +51,6 @@
{"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}}
{"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"71540121-7bf6-410e-975c-f5ea99d8175a"}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
{"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}}
{"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -5,54 +5,58 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
- button "复制":
- button "Copy":
- img
- button "在新对话中分支":
- button "Branch into a new conversation":
- img
- button "编辑":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to:":
- img
- img
- text: "Think The user wants me to:"
- button:
- button "Inspect temporary":
- img
- img
- text: Inspect temporary
- text: Inspect temporary
- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
- img
- img
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
- button [expanded]:
- 'button "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" [expanded]':
- img
- text: Mount temporary Plugin typescript
- button "复制"
- text: "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }"
- text: typescript
- button "Copy"
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- text: OUT Temporary Plugin dyn-1 is running (plugin "snapshot-noop"; available until unmounted or DSH restarts).
- button "Inspect"
- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
- img
- img
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
- button:
- button "Unmount temporary Plugin dyn-1":
- img
- img
- text: Unmount temporary Plugin dyn-1
- text: Unmount temporary Plugin dyn-1
- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
- img
- img
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
- paragraph: CORDIS_UI_DONE
- button "复制":
- button "Copy":
- img
- button "在新对话中分支":
- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 77% Input 66.5K tok · Output 312 tok
- text: 1 turns · 4 steps Tool call {{duration}} Context 13% of 128K Cache hit 77% Input 66.5K tok · Output 312 tok

Some files were not shown because too many files have changed in this diff Show More