Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.gitignore
#	apps/cli/package.json
#	apps/cli/src/web.ts
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
#	docs/module-graph.md
#	packages/client/connection/README.md
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/src/client/workspaces/service.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/host/apiproxy/src/api-proxy.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-27 12:27:30 +08:00
1799 changed files with 63595 additions and 13464 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 36cb9cbe9071cf5af4d7137f1195eaaefa88f1f1
README.zh.md: 0ed71a1f0e51715eda30e1f5d58a62f3bf5a6095

View File

@@ -1,17 +1,23 @@
# `@deepseek-ai/dsh`
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
English | [中文](README.zh.md)
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
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`) 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. `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 `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
The TUI surface:
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
- 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 flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config <path>` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
- 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 `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, 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;
- 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.
The Web and headless surfaces boot one shared composition (`cordis.yml`): 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. `dsh web --provider <name> --model <id>` mounts that pi-ai catalog route with provider-native ambient credentials; the default DeepSeek route remains text-only. 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`).
`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).
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:

29
apps/cli/README.zh.md Normal file
View File

@@ -0,0 +1,29 @@
# `@deepseek-ai/dsh`
[English](README.md) | 中文
`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent智能体`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。
TUI 界面:
- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config <path>` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动;
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume <id>` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id不使用环境变量已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析;
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env``config.yaml` 则修补已启动的树。
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider <name> --model <id>` 会挂载对应的 pi-ai catalog 路由,并使用提供方原生的环境凭据;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
## 安装(开发机)
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout因此代码更改会在下次启动时生效无需构建
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。

View File

@@ -47,6 +47,18 @@
- id: tools
name: '@deepseek-ai/dsh-tools'
config:
# TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh
# process into Code Mode while per-session tool-mode selection is being
# designed; unset keeps the schema default (native). Remove the env seam
# once the web UI owns the choice per session.
mode: !!js process.env.DSH_TOOLS_MODE
# Code Mode substrate for the row above. Mounted unconditionally because
# Loader metadata is static (no conditional rows): a native-mode boot only
# registers the service — a worker thread spawns per run_code execution.
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
- id: user-interaction
name: '@deepseek-ai/dsh-user-interaction'
@@ -55,7 +67,7 @@
name: '@deepseek-ai/dsh-agent'
- id: tasks
name: '@deepseek-ai/dsh-tasks'
name: '@deepseek-ai/dsh-tasks-local'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
@@ -70,6 +82,11 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# Transient-failure recovery around the loop's model calls (same policy as
# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff).
- id: llm-retry
name: '@deepseek-ai/dsh-llm-retry'
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
@@ -131,6 +148,30 @@
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# Host command registry: the single source of truth behind command.list /
# command.execute; the web '/' menu is a pure projection of this registry.
- id: commands
name: '@deepseek-ai/dsh-commands'
# Plan mode registers /plan (the first real command on the web surface).
# Section text mirrors examples/tui-agent/cordis.yml (the reference
# deployment); plan-mode throws at load on an empty section.
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# token-meter rejects unknown config keys — keep this row bare.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
@@ -224,8 +265,8 @@
- id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme'
- id: i18n
name: '@deepseek-ai/dsh-client-i18n'
- id: locale
name: '@deepseek-ai/dsh-client-locale'
- id: ui-layout
name: '@deepseek-ai/dsh-client-ui-layout'
@@ -233,12 +274,35 @@
- id: ui-sidebar
name: '@deepseek-ai/dsh-client-ui-sidebar'
- id: ui-settings
name: '@deepseek-ai/dsh-client-ui-settings'
- id: ui-settings-general
name: '@deepseek-ai/dsh-client-ui-settings-general'
- id: ui-models
name: '@deepseek-ai/dsh-client-ui-models'
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
- id: ui-workspace
name: '@deepseek-ai/dsh-client-ui-workspace'
# Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over
# it (ui-command), and the two reference sources (ui-skill / ui-subagent).
- id: ui-slash
name: '@deepseek-ai/dsh-client-ui-slash'
- id: ui-command
name: '@deepseek-ai/dsh-client-ui-command'
- id: ui-skill
name: '@deepseek-ai/dsh-client-ui-skill'
- id: ui-subagent
name: '@deepseek-ai/dsh-client-ui-subagent'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'

View File

@@ -24,16 +24,25 @@
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-skill": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
@@ -43,7 +52,9 @@
"@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-paths": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
@@ -59,7 +70,7 @@
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
@@ -76,6 +87,7 @@
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"commander": "^15.0.0",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"
},

152
apps/cli/src/args.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* 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; `web` is a
* real subcommand. 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
*/
import { Command, CommanderError } from 'commander'
/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
interface TuiInvocation {
mode: 'tui'
config?: string
resume?: string
}
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode: 'headless'
prompt: string
}
/**
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
* passed — pass-through overrides with no CLI default and no CLI validation:
* the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
* `port` a natural ≤ 65535) is the single source of both the default (the
* shipped `cordis.yml` value stands when a flag is absent) and validity (a bad
* value fails loud at boot). `port` is `Number`-coerced only because the schema
* wants a number, not a string. `dev` mounts the client HMR driver;
* `workspaceRoot` is the parent directory for name-created workspaces.
* `provider` and `model` override the host's default model route.
*/
interface WebInvocation {
mode: 'web'
host?: string
port?: number
dev: boolean
workspaceRoot?: string
provider?: string
model?: string
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
host?: string
port?: string
dev?: boolean
workspaceRoot?: string
provider?: string
model?: string
}
/**
* Narrow the raw `web` options into a {@link WebInvocation}. No host/port
* validation: both flow to the webserver schema, which is the sole gate. `port`
* is coerced to a number (the schema rejects a string) but not range-checked
* here — `NaN`/out-of-range fail loud at the schema on boot.
*/
function resolveWeb(options: WebOptions): WebInvocation {
return {
mode: 'web',
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.provider !== undefined && { provider: options.provider },
...options.model !== undefined && { model: options.model },
}
}
/**
* Resolve the raw argv into a {@link DshInvocation}, or print and exit for
* `--help`/`--version`/a parse error. The default (no subcommand) is the
* 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.
* @returns the resolved invocation (only reached on a valid, non-help invocation).
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
let resolved: DshInvocation | undefined
const program = new Command()
.name('dsh')
.version(version, '-V, --version', 'output the version number')
.description('dsh: interactive TUI (default), headless task, and browser UI')
.exitOverride()
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
.option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
.option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
.option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
.action((options: { config?: string; prompt?: string; resume?: string }) => {
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
// vanish from a headless run.
if (options.prompt === '') program.error('error: --prompt needs a task')
if (options.config !== undefined || options.resume !== undefined) {
program.error('error: --prompt takes no --config or --resume')
}
resolved = { mode: 'headless', prompt: options.prompt }
return
}
// An empty --resume= id would silently start a fresh session downstream
// (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
if (options.resume === '') program.error('error: --resume needs a session id')
resolved = {
mode: 'tui',
...options.config !== undefined && { config: options.config },
...options.resume !== undefined && { resume: options.resume },
}
})
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
web
.option('--host <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
.option('--provider <name>', 'override the host default provider')
.option('--model <id>', 'override the host default model')
.action((options: WebOptions) => {
// Commander parses the parent (default-surface) options on either side of
// the subcommand into `program.opts()`. `web` shares none of them, so a
// leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
// fail loud rather than silently start the web server and drop it.
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
program.error('error: web takes none of --config, -p/--prompt, or --resume')
}
resolved = resolveWeb(options)
})
try {
program.parse(argv, { from: 'user' })
} catch (error) {
// Commander printed help/version/the error under `exitOverride`; exit with
// the code it chose (0 for help/version, 1 for a parse or domain error).
/* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
return process.exit(error instanceof CommanderError ? error.exitCode : 1)
}
/* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
if (resolved === undefined) throw new Error('dsh: no invocation resolved')
return resolved
}

View File

@@ -1,25 +1,56 @@
#!/usr/bin/env node
/**
* dsh — command-line entry. Coarse dispatch only; each surface module owns its
* argument handling. Dynamic imports keep unrelated surfaces out of each
* dispatch path; everything except `web` and headless prompts opens the TUI.
* dsh — command-line entry. Dynamic imports per mode keep unrelated modes out
* of each dispatch path; the adapter prints and exits for
* `--help`/`--version`/a parse error, so only a valid mode reaches the switch.
* @module @deepseek-ai/dsh/bin
*/
/* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the checked-in manifest resolves with the
// same relative hop from either artifact.
/** This app's version, read from its checked-in package.json. */
function readVersion(): string {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
) as { version?: unknown }
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
loadEnv('dsh')
const argv = process.argv.slice(2)
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
if (argv[0] === 'web') {
const { runWeb } = await import('./web.ts')
await runWeb(argv.slice(1))
} else if (argv.includes('-p') || argv.includes('--prompt')) {
const { runHeadless } = await import('./headless.ts')
await runHeadless(argv)
} else {
const { runTui } = await import('./tui.ts')
await runTui(argv)
switch (invocation.mode) {
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(
invocation.host,
invocation.port,
invocation.dev,
invocation.workspaceRoot,
invocation.provider,
invocation.model,
)
break
}
case 'headless': {
const { runHeadless } = await import('./headless.ts')
await runHeadless(invocation.prompt)
break
}
case 'tui': {
const { runTui } = await import('./tui.ts')
await runTui(invocation.config, invocation.resume)
break
}
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
}

View File

@@ -8,7 +8,6 @@
* the final assistant text, exits (completed → 0, else 1).
*/
import { parseArgs } from 'node:util'
import { fileURLToPath } from 'node:url'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -67,18 +66,13 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
return { text, reason: 'error' }
}
export async function runHeadless(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: { prompt: { type: 'string', short: 'p' } },
allowPositionals: false,
})
const task = values.prompt
if (task === undefined || task === '') {
process.stderr.write('usage: dsh -p "task"\n')
process.exit(1)
}
/**
* Run one headless turn for `task` and exit (completed → 0, else 1). The task
* is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
* (the adapter rejects an empty task, so no guard is needed here).
* @param task - the prompt text for the single turn.
*/
export async function runHeadless(task: string): Promise<void> {
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const entry = new AppCLIEntry({
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),

View File

@@ -1,6 +1,6 @@
/**
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
* tui-agent config (or an explicit config argument) with the personal overlay
* tui-agent config (or the `--config` override) with the personal overlay
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
@@ -18,8 +18,7 @@ import {
installFailLoud,
loadEnv,
loadPersonalPatches,
parseResumeArg,
replaceResumeArg,
RESUME_SESSION_ID_KEY,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -28,12 +27,6 @@ import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
const NAME = 'dsh'
// The env var the shipped tui-agent config reads (`resumeSessionId: !!js
// process.env.RESUME_SESSION_ID`) to rehydrate a persisted session. The
// `--resume <id>` flag is CLI sugar that sets it before boot, so the printed
// `dsh --resume <id>` exit hint runs back through this same intake.
const RESUME_SESSION_ID_ENV = 'RESUME_SESSION_ID'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the shipped default config resolves with
// the same relative hop from either artifact.
@@ -48,26 +41,30 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
the tui-agent PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI from the invoking directory.
* @param argv - arguments after the subcommand dispatch; a `--resume <id>` flag
* resumes that persisted session, and the first non-flag argument may name a
* config to boot instead of the shipped default.
* @param config - a config path to boot instead of the shipped default, or
* `undefined` for the default; already parsed from `--config`.
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
* already parsed and non-empty-validated from `--resume`. It is provided on the
* boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
* reads through `!!js` to rehydrate that session.
*/
export async function runTui(argv: string[]): Promise<void> {
export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise<void> {
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
// is logged per-entry rather than rethrown, so a piped launch would
// otherwise settle into an idle UI-less process instead of exiting nonzero.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`)
process.stderr.write(
`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`,
)
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())
// An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it
// after loadEnv and before boot reads it through the config's `!!js`.
const { resumeSessionId, rest } = parseResumeArg(argv)
if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
// flag, so the resumed process rehydrates through this same intake. The host
// is offered only when Node exposes `process.execve` and knows its own entry.
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
@@ -75,13 +72,15 @@ export async function runTui(argv: string[]): Promise<void> {
async handoff(sessionId): Promise<never> {
const current = app.current
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
// Rebuild argv from the parsed config plus the selected id: TUI mode's
// only arguments are `--config <path>` and `--resume <id>`.
const nextArgv = [
process.execPath,
...process.execArgv,
entry,
...replaceResumeArg(process.argv.slice(2), sessionId),
`--resume=${sessionId}`,
...config !== undefined ? ['--config', config] : [],
]
process.env[RESUME_SESSION_ID_ENV] = sessionId
try {
await current.fiber.dispose()
execve(process.execPath, nextArgv, process.env)
@@ -94,9 +93,12 @@ export async function runTui(argv: string[]): Promise<void> {
}
const ctx = await boot(
NAME,
resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined),
resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
loadPersonalPatches(NAME),
(hostCtx) => {
// Inject the resume id (or undefined) so the shipped config's `!!js`
// reads it as a bare identifier; then offer the in-place handoff host.
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
},
)

View File

@@ -1,55 +1,49 @@
/**
* `dsh web` — thin bin over the config-tree boot: parse argv, run
* AppCLIEntry, print the URL line, wire signals. All composition lives in
* cordis.yml; all boot glue lives in AppCLIEntry.
* `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the
* already-parsed host/port/dev, print the URL line, wire signals. All
* composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and
* port are unvalidated pass-through overrides — the `dsh-host-webserver` schema
* gates them at boot.
*/
import { parseArgs } from 'node:util'
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
// Display-only mirrors of the webserver schema's allowed hosts: the loopback
// address the local URL always prints, and the all-interfaces value that gates
// LAN-address discovery. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
export async function runWeb(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: {
host: { type: 'string' },
port: { type: 'string' },
dev: { type: 'boolean', default: false },
'workspace-root': { type: 'string' },
provider: { type: 'string' },
model: { type: 'string' },
},
allowPositionals: false,
})
if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
process.stderr.write(
`dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
)
process.exit(1)
}
let port: number | undefined
if (values.port !== undefined) {
port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
}
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the `cordis.yml` 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 workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param provider - provider override, or `undefined` to keep the profile/config route.
* @param model - model override, or `undefined` to keep the profile/config route.
*/
export async function runWeb(
host: string | undefined,
port: number | undefined,
dev: boolean,
workspaceRoot: string | undefined,
provider: string | undefined,
model: string | undefined,
): Promise<void> {
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
dev: values.dev,
...values.host !== undefined ? { host: values.host } : {},
...port !== undefined ? { port } : {},
...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {},
...values.provider !== undefined ? { provider: values.provider } : {},
...values.model !== undefined ? { model: values.model } : {},
dev,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...provider !== undefined && { provider },
...model !== undefined && { model },
})
const { ctx, port: boundPort } = await entry.run()
@@ -60,7 +54,7 @@ export async function runWeb(argv: string[]): Promise<void> {
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lanCandidate = values.host === ALL_INTERFACES_HOST
const lanCandidate = host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
/**
* `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 {
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)
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
} catch {
return exit.mock.calls.at(-1)?.[0] as number
} finally {
vi.restoreAllMocks()
}
}
afterEach(() => { vi.restoreAllMocks() })
describe('parseDshArgs', () => {
it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
expect(parse([])).toEqual({ mode: 'tui' })
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.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' })
// Bare `web` carries no host/port: the shipped cordis.yml owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
// Host/port are unvalidated pass-throughs (the webserver schema gates them
// at boot); the adapter only coerces the port string to a number.
expect(parse([
'web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w',
'--provider', 'anthropic', '--model', 'claude-opus-4-8',
])).toEqual({
mode: 'web',
host: '0.0.0.0',
port: 8080,
dev: true,
workspaceRoot: '/w',
provider: 'anthropic',
model: 'claude-opus-4-8',
})
})
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
// schema at boot, not here.)
expect(exitCode(['--resume='])).toBe(1)
expect(exitCode(['-p', ''])).toBe(1)
expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
expect(exitCode(['--bogus'])).toBe(1)
expect(exitCode(['bogus-positional'])).toBe(1)
// A default-surface flag on either side of `web` leaks into program.opts()
// but the web subcommand shares none of them: reject rather than serve.
expect(exitCode(['web', '-p', 'task'])).toBe(1)
expect(exitCode(['web', '--resume', 's'])).toBe(1)
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
})
it('exits 0 for --help (disclosing web) and --version', () => {
expect(exitCode(['--help'])).toBe(0)
expect(exitCode(['--version'])).toBe(0)
})
})

View File

@@ -0,0 +1,55 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
* plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot.
* `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a
* piped launch must exit nonzero with a stderr pointer at the one-shot `-p`
* mode. The guard fires inside `runTui` BEFORE the Loader resolves the config
* tree — a compose-time throw inside the tree is logged per-entry, not
* rethrown, so without this guard a piped launch would settle into an idle
* UI-less process. The bin resolves its workspace deps through the repo's
* node_modules, so no external consumer is assembled; missing-config fail-loud
* and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
* built-bin suite, and interactive TTY behavior is PTY-covered by
* examples/tui-agent. Skips before the bin is built.
*/
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
/** Run the built bin with PIPED stdio; resolve with output + exit code. */
function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (c: string) => { stdout += c })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
// Resolve on `close` (all stdio drained), not `exit`, so captured output is complete.
child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
child.on('error', (err) => { clearTimeout(timer); reject(err) })
child.stdin.end()
})
}
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
const { stdout, code, stderr } = await runBuiltBin()
expect(code).not.toBe(0)
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
expect(stderr).toContain('dsh -p')
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
})

View File

@@ -42,7 +42,16 @@
"path": "../../packages/client/ui-theme"
},
{
"path": "../../packages/client/i18n"
"path": "../../packages/client/ui-settings"
},
{
"path": "../../packages/client/ui-settings-general"
},
{
"path": "../../packages/client/ui-models"
},
{
"path": "../../packages/client/locale"
},
{
"path": "../../packages/client/ui-layout"

View File

@@ -0,0 +1,257 @@
// @vitest-environment jsdom
// Code Mode fixture snapshot over the BUILT client graph (the workspace-flow
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
// Opens the fixture history session and pins the run_code turn's rendering:
// the code-variant parent row titled by the model-authored description, its
// three always-visible nested sub-rows (bash through the sample registration,
// read through GenericToolCard, the failing read wearing the error state),
// the expanded program body, details-panel resolution of a sub-callId, and
// the trajectory/waterfall tabs' sub-call cells and timing lanes.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
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
beforeEach(() => {
localStorage.clear()
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__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
vi.unstubAllGlobals()
})
/** Boot the complete built client graph against the populated fixture branch. */
function boot(): void {
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() }
})
}
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */
async function openFixtureSession(): Promise<void> {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const group = within(tree).getByText('4 sessions').closest<HTMLElement>('[role="treeitem"]')
if (group === null) throw new Error('fixture Workspace group missing')
if (group.getAttribute('aria-expanded') === 'false') {
fireEvent.click(within(group).getByText('fixture'))
await waitFor(() => {
expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true')
})
}
const session = await within(tree).findByText('Fixture 历史会话')
fireEvent.click(session)
await waitFor(() => {
expect(document.querySelector('[data-variant="code"]')).not.toBeNull()
}, { timeout: 10_000 })
}
it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => {
boot()
await openFixtureSession()
const codeRoot = document.querySelector('[data-variant="code"]')
if (codeRoot === null) throw new Error('code-variant row missing')
const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]')
if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row')
expect({
parentRow: visibleText(codeRoot),
// The three sub-rows in dispatch order: bash rides the sample plugin's
// keyed registration (the same one a native top-level bash row uses),
// both reads ride GenericToolCard.
bashSample: nest.querySelector('[data-sample="bash-global"]') !== null,
subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText),
errorSubRow: nest.querySelector('[data-state="error"]') !== null,
}).toMatchInlineSnapshot(`
{
"bashSample": true,
"errorSubRow": true,
"parentRow": "CodeRead the notes files and summarize",
"subRows": [
"$List notes",
"Readnotes/demo.txt",
"Readnotes/missing.txt",
],
}
`)
})
it('expands the code row into the program body and resolves a sub-row through the details panel', async () => {
boot()
await openFixtureSession()
// Expand: the leading control reveals the program (shiki-tokenized: the
// text splits into styled spans inside one <pre class="shiki"> tree).
const codeRoot = document.querySelector('[data-variant="code"]')
if (codeRoot === null) throw new Error('code-variant row missing')
const toggle = codeRoot.querySelector('button[aria-expanded]')
if (toggle === null) throw new Error('code row expand control missing')
fireEvent.click(toggle)
await waitFor(() => {
// Scope to THIS row: the markdown fixture turn also renders shiki pres.
const pre = codeRoot.querySelector('pre.shiki')
if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
throw new Error('highlighted program body missing under the code row')
}
})
// Sub-row click → details panel resolves the sub-callId with FULL output.
const nest = document.querySelector('[data-subcalls]')
if (nest === null) throw new Error('sub-call nest missing')
const bashRow = nest.querySelector('[data-sample="bash-global"]')
if (bashRow === null) throw new Error('bash sample sub-row missing')
fireEvent.click(bashRow)
const details = await screen.findByText('Input')
const panel = details.closest('[class*="root"]')
if (panel === null) throw new Error('details panel missing')
expect({
title: visibleText(within(panel as HTMLElement).getByText('bash')),
inputEchoesArgs: visibleText(panel).includes('ls notes'),
outputComplete: visibleText(panel).includes('demo.txt new-demo.txt')
|| visibleText(panel).includes('demo.txt\nnew-demo.txt')
|| (panel.textContent ?? '').includes('demo.txt\nnew-demo.txt'),
}).toMatchInlineSnapshot(`
{
"inputEchoesArgs": true,
"outputComplete": true,
"title": "bash",
}
`)
})
it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => {
boot()
await openFixtureSession()
// Switch to the trajectory tab (same slot ring the chat view registers in).
fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
await waitFor(() => {
expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
}, { timeout: 10_000 })
const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
expect({
// Three Sub cells nested under the run_code Tool cell, in dispatch order,
// each with a real +N.Ns own-duration off the start/settle pair (the
// fixture spaces every event 800ms apart — never the em dash).
subCells: subCells.map(cell => visibleText(cell)),
}).toMatchInlineSnapshot(`
{
"subCells": [
"#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
"#54Subread · {"path":"notes/demo.txt"}+0.8s",
"#55Subread · {"path":"notes/missing.txt"}+0.8s",
],
}
`)
// Waterfall: each sub-call draws a measured lane scaled into the parent
// turn's dispatch window.
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
await waitFor(() => {
expect(document.querySelector('[data-subspan]')).not.toBeNull()
}, { timeout: 10_000 })
const lanes = [...document.querySelectorAll('[data-subspan]')]
expect({
lanes: lanes.map(lane => ({
label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane),
title: lane.querySelector('[data-timing]')?.getAttribute('title'),
timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'),
})),
}).toMatchInlineSnapshot(`
{
"lanes": [
{
"label": "bash",
"timing": "measured",
"title": "bash · 0.80s",
},
{
"label": "read",
"timing": "measured",
"title": "read · 0.80s",
},
{
"label": "read",
"timing": "measured",
"title": "read · 0.80s",
},
],
}
`)
})

View File

@@ -0,0 +1,146 @@
// Web e2e scenario: a Code Mode round trip. The scaffold boots the SAME
// shipped tree with the tools row patched to mode: code (the run_code-only
// wire), a real chromium sends a prompt engineered to elicit one run_code
// program with several sub-calls, and the UI must render the code-variant
// parent row with its always-visible nested sub-rows — each sub-row the same
// component a native call renders through — plus details-panel resolution for
// a clicked sub-row. Drive steps wait only on generic completion
// (whenTurnSettled); assertion steps run in replay/refresh only.
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// The scenario's one drive prompt: elicits one program with a bash sub-call
// and a failing read the program tolerates — the sub-row set the assertions
// (and the PR gif) need. Never asserted against model prose.
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
describe('web e2e: Code Mode round renders nested sub-calls', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({
toolsMode: 'code',
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
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)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('drives the recorded prompt to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive'))
if (MODE !== 'record') {
// Drift guard: the committed fixture must carry exactly the drive prompt.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 200_000)
it.skipIf(MODE === 'record')('the durable log carries run_code with full-content sub-dispatches', () => {
// Wire discipline: code mode collapsed the call surface to run_code.
const calls = sessionEvents.filter(event => event.type === 'tool/call')
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(new Set(calls.map(call => (call.data as { name: string }).name))).toEqual(new Set(['run_code']))
// Sub-dispatches logged with the complete tool/result vocabulary.
const dispatches = sessionEvents.filter(event => (event.type as string) === 'tool/code-dispatch')
expect(dispatches.length).toBeGreaterThanOrEqual(2)
for (const dispatch of dispatches) {
const data = dispatch.data as unknown as {
parentCallId: string
subCallId: string
name: string
isError: boolean
content: { type: string }[]
}
expect(data.subCallId.startsWith(`${data.parentCallId}:code:`)).toBe(true)
expect(Array.isArray(data.content)).toBe(true)
expect(typeof data.isError).toBe('boolean')
}
const bash = dispatches.find(dispatch => (dispatch.data as { name: string }).name === 'bash')
expect(bash).toBeDefined()
const bashContent = (bash!.data as { content: { type: string; text?: string }[] }).content
expect(bashContent.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('CODE_ROUND_OK')
})
it.skipIf(MODE === 'record')('renders the code parent row with always-visible nested sub-rows', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// The parent run_code row wears the code variant with the model-authored
// description as its summary (the PR1 presentCall contract).
const codeRow = page.locator('[data-variant="code"]').first()
await codeRow.waitFor({ timeout: 10_000 })
// Nested rows are visible WITHOUT any expand interaction, inside the
// sub-call nest, each rendered by the same components as native rows:
// 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)
// 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)
}, 60_000)
it.skipIf(MODE === 'record')('a sub-row click opens the details panel on the sub-call material', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
const nest = page.locator('[data-subcalls]').first()
await nest.locator('[data-sample="bash-global"]').first().click()
// The details column opens (width > 0) and shows the sub-call's complete
// output — the full-content log contract, no truncation marker anywhere.
await page.waitForFunction(() => {
const frame = document.querySelector('[class*="frame"]')
if (frame === null) return false
return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
}, undefined, { timeout: 10_000 })
await expect.poll(() => page.getByText('CODE_ROUND_OK', { exact: false }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1)
})
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria'))
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -0,0 +1,165 @@
// Web e2e scenarios: lifecycle & chrome — the workspace-aware first-send
// flow over the real wire, reload recovery, and the dark-mode token cascade.
// One tiny recorded turn (text-only) drives the whole spec: the empty-state
// hero materializes a real Workspace + Session on first send (the jsdom
// workspace-flow suite pins the object-layer state machine over the fixture
// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
// gateway), reload replays everything from the log (zero further model
// calls), and the theme scenario proves the shipped dark palette actually
// cascades: attribute -> alias token flip -> painted surface change. Per the
// lane's scope ruling there is no theme/layout golden (aria is color-blind);
// the hero's waiting state gets the one golden here.
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 {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
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')
// 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')
const MODE = webSnapshotMode()
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
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 browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
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)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('sends the first prompt from the empty-state hero (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
// The blank frame renders the hero, not the resident composer: the
// headline plus the guidance placeholder are the empty state's anchors.
await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
if (MODE !== 'record') {
// Golden of the hero's stable waiting state (captured before any send;
// the conversation-region goldens belong to the other scenarios).
const snapshot = await captureStableAria(page, '[class*="frame"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
}
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 200_000)
it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
// Browser: the sidebar tree now carries the auto-created workspace group
// with its one session, and the opened session is the selected row.
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.
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')
expect(turnEnds).toHaveLength(1)
expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
}, 60_000)
it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
// Fold a layout preference into the same reload: collapse the sidebar
// (persisted under dsh.layout.panels) before reloading.
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
// Layout persisted: the sidebar comes back collapsed.
await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
// Selection persisted (dsh.sessions.current) and history replayed: the
// recorded turn re-renders from session.history with zero model calls —
// the replay cursor was fully consumed before the reload, so any stray
// request would fail the scenario loudly at close().
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Expand back and confirm the tree still lists the materialized session.
await page.getByRole('button', { name: 'Open sidebar' }).click()
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
// Golden of the recovered conversation region: rebuilt from the log, it
// must render the same settled transcript the live turn produced.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
// This scenario pins the ThemeService's DOM contract seam directly (the
// body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
// user gesture above it (Settings -> Appearance cubes) is owned by
// settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
// pinned independently of the settings surface's own lifecycle.
const sample = async (): Promise<{ token: string; sidebarBg: string; bodyBg: string }> =>
await page.evaluate(() => {
const sidebar = document.querySelector('[class*="sidebar"], [class*="rail"]') ?? document.body
return {
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
sidebarBg: getComputedStyle(sidebar).backgroundColor,
bodyBg: getComputedStyle(document.body).backgroundColor,
}
})
const light = await sample()
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await sample()
// The alias token itself must flip — the cascade's root fact.
expect(dark.token).not.toBe(light.token)
// And a real painted surface must consume it (not just variables in a
// void): at least one of the sampled backgrounds repaints.
expect(dark.sidebarBg !== light.sidebarBg || dark.bodyBg !== light.bodyBg).toBe(true)
// Removing the attribute restores the light values exactly (the palettes
// live in one stylesheet; activation is attribute-only by design).
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
const restored = await sample()
expect(restored).toEqual(light)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
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'])
})
})

View File

@@ -0,0 +1,211 @@
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
// and transient-retry recovery, all through the real composition and wire.
// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
// readyFile marker) makes mid-stream cancel deterministic by construction,
// `throw` entries express provider failures by stable code, and `{ patches }`
// augmentation injects a transient throw before the recorded success so
// llm-retry's recovery is proven end-to-end in the browser. Sidecar CONTENT
// is authored here (single-sourced against the fixture via deriveReplayScript
// — no committed copy of recorded chunks); the file is a per-run artifact in
// the temp workspace. One recorded base fixture serves all three scenarios.
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
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 } from '@deepseek-ai/dsh-llm-replay'
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
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.
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
const MODE = webSnapshotMode()
// The recorded base: one text-only turn whose derived script the sidecars
// patch. Kept deliberately tool-free so the derived script is exactly one
// model call.
const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
/** turn/end reasons observed, in order. */
function turnEndReasons(events: SessionEvent[]): string[] {
return events
.filter(e => e.type === 'turn/end')
.map(e => (e as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind)
}
describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let sessionEvents: SessionEvent[]
let sidecarDir: string | undefined
afterEach(async () => {
// scaffold.close() failures MUST fail the scenario: assertConsumed() is
// the fixture-drift tripwire and cleanup problems are real defects. Run
// every teardown step regardless, then rethrow what failed.
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
browser = undefined
const closing = scaffold
scaffold = undefined
await closing?.close().catch((error: unknown) => failures.push(error))
if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
sidecarDir = undefined
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'live-interactions teardown failed')
})
/** Boot scaffold + page with an optional override doc materialized per run. */
async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise<void> {
sessionEvents = []
let overridePath: string | undefined
if (buildOverride !== undefined) {
// The sidecar CONTENT is authored in this spec; the file is a per-run
// artifact minted in a spec-owned temp dir. It must exist BEFORE the
// scaffold boots — installLlmReplay resolves the script at install.
sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
overridePath = join(sidecarDir, 'replay.override.json')
await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir)))
}
scaffold = await launchWebScaffold({
replayFixture: FIXTURE,
...(overridePath === undefined ? {} : { replayOverride: overridePath }),
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
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)
}
/**
* Type the recorded prompt and send, with the settled barrier pre-armed.
* Returned WRAPPED ({ settled }) — a bare returned promise would be
* flattened by the caller's await, blocking on turn/end before the caller
* can act mid-turn (the cancel scenario's whole point).
*/
async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> {
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold!.whenTurnSettled(timeoutMs)
await input.fill(PROMPT)
await input.press('Enter')
return { settled }
}
it.skipIf(MODE !== 'record')('records the base fixture live through the composer', async () => {
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-interactions-record'))
const { settled } = await sendPrompt(180_000)
const sessionId = await settled
await recordFixture(scaffold!, sessionId, FIXTURE)
}, 200_000)
it.skipIf(MODE === 'record')('cancels a hung stream deterministically via the readyFile marker', async () => {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
let marker = ''
await launch((sidecarHome) => {
marker = join(sidecarHome, '.hang-ready')
return { patches: [{ at: 0, entry: { kind: 'hang', readyFile: marker } }] }
})
onTestFailed(() => saveFailureShot(page, 'web-e2e-cancel'))
const { settled } = await sendPrompt()
// 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 page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
// Composer recovered; no streaming node lingers. The host settled first
// (awaited above), but the abort frame reaches the browser over SSE — the
// frozen-partial swap is eventually consistent, so poll rather than count.
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
// Golden of the aborted end-state: the prompt bubble plus the frozen
// partial ('partial' is the hang entry's replayed prefix) and no more.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
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' } }],
}))
onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth'))
const { settled } = await sendPrompt()
await settled
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)
// 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 snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => {
const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
expect(derived).toHaveLength(1)
await launch(() => ({
patches: [
{ at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } },
// Append the fixture's own success as the retry attempt — single-
// sourced from the recording, never copied into a committed sidecar.
{ at: 1, entry: derived[0]! },
],
}))
onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
// llm-retry backs off ~500ms before the second attempt.
const { settled } = await sendPrompt(60_000)
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
// The durable retry record proves the second attempt (request/header logs
// 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.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
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',
])
})
})

View File

@@ -0,0 +1,190 @@
// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
// Waterfall), the details column, and sidebar search, all over ONE rich
// two-turn seeded fixture rendered purely from the log (the seeded-history
// pattern: zero model calls in replay, so every surface here is the client
// fold + host history RPC, not replay binding). The seed is recorded live
// under the standard discipline: turn 1 produces a bash call plus two
// parallel reads in one assistant message (tool-call density for the
// trajectory/waterfall lanes and a details-capable bash row), turn 2 a
// markdown-rich reply (a second turn so the waterfall has two lanes).
import { mkdir, readFile, writeFile } 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 { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { 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 WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
// Turn 1 leads with a distinctive word: the session-title fallback takes the
// first words of the first message, so the sidebar-search scenario has a
// known-matching query ('navscenario') without depending on a live title call.
const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
describe('web e2e: navigation & panes over a rich seeded session', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/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')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n')
await writeFile(join(sessionCwd, 'nav-b.md'), '# beta nav\n')
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
.toEqual([PROMPT_TURN1, PROMPT_TURN2])
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record'))
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) {
const settled = scaffold.whenTurnSettled()
// Turn 2 types into the same composer once turn 1 unlocks it.
await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
await input.fill(prompt)
await input.press('Enter')
sessionId = await settled
}
await recordFixture(scaffold, sessionId!, SEED)
// Fixture honesty: the recording must carry the shape the replay
// scenarios assert on — three calls in turn 1 and two closed turns.
const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')
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()
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)
it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await page.getByRole('tab', { name: 'Trajectory' }).click()
// Two sticky turn sections; turn 1's step group summarizes its tool mix
// (bash + the two parallel reads collapse to 'bash read×2').
await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
await page.getByRole('tab', { name: 'Waterfall' }).click()
// The stats header rides the waterfall body. The span fold counts THREE
// spans for this two-turn log: only assistant/steering nodes carry a turn
// number, so the first user message lands in a turn-0 prologue span (a
// P-I placeholder shape — pinned as-is; real spans are deferred to
// P-III per the view's deviation ledger). Calls: bash + two reads.
await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
// One lane per span, tagged by turn number, prologue included.
for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
}
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click()
// The bash toolview row routes its click to openDetails (read rows are
// expand-in-place instead — the seeded-history scenario owns that fold).
const bashRow = page.locator('[data-sample="bash-global"]').first()
await bashRow.waitFor({ timeout: 15_000 })
// Open/closed is the frame's collapsed attribute: the column collapses to
// width 0 but its subtree deliberately never unmounts (hidden, not
// absent), so element presence/visibility cannot express the state.
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
await bashRow.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
// The open panel shows the selected call's name, arguments, and durable
// result (NAVIGATION_OK appears in the chat row too, hence >= 2 total).
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
// Golden of the open panel: tool name header, Input args, Output result.
const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
await page.getByRole('button', { name: '关闭详情' }).click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
}, 60_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
])
})
})

View File

@@ -0,0 +1,109 @@
// Web e2e scenario: the resident question composer. The shipped composition
// already exposes ask_user_question (the ui-question row's node half mounts
// the tool), so a recorded turn where the model asks blocks mid-turn on the
// real userInteraction seam: the composer renders in the browser, the test
// answers through it, and the turn completes with the answer in the log.
// Replay is fully deterministic — the question content arrives from replayed
// chunks, the composer wait is real, and the answer click is the test's own
// gesture (the ONE place a drive step legitimately reacts to model content:
// 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, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
// Second golden: the answered transcript — the question resolved into its
// tool round trip and the final reply, the state the waiting golden cannot see.
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.'
describe('web e2e: resident question composer 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()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
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)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('asks through the composer, answers, and completes with the answer logged', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
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(PROMPT)
await input.press('Enter')
// The composer takes over the input area while the tool blocks. Its
// presence is a STABLE waiting state (not a transient): it stays until
// answered, so a plain waitFor is race-free.
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
if (MODE !== 'record') {
// This golden owns the stable question surface; the answered-state
// golden below owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}
await composer.getByRole('radio', { name: 'Blue' }).click()
// Submit: Enter on the focused option (the composer's documented submit).
await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
return
}
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents.filter(e => e.type === 'tool/result')
expect(JSON.stringify(results.at(-1))).toContain('Blue')
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Composer gone; regular input restored.
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
// Golden of the answered transcript: the ask_user_question round trip
// rendered as history (question tool row + DONE), composer takeover gone.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ANSWERED_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', 'ui.expected.md', 'answered.expected.md'])
})
})

View File

@@ -0,0 +1,131 @@
// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo
// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless)
// or the live adapter (record). Drive steps run in every mode and wait only
// on generic completion (whenTurnSettled — never model-content selectors, so
// record cannot hang on a live model answering differently); assertion steps
// run in replay/refresh only. Settled states only — streaming incrementality
// is asserted from the persisted assistant/chunk events, not transient DOM.
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, 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 MODE = webSnapshotMode()
// The scenario's one drive prompt. Record sends it; replay asserts the
// committed fixture recorded exactly it, so drive script and fixture cannot
// drift apart.
const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.'
describe('web e2e: fresh round trip through the real assembly', () => {
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 browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
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)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('drives the recorded prompt to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip'))
if (MODE !== 'record') {
// Drift guard: the committed fixture must carry exactly the drive prompt.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
// Arm the host-side settled barrier BEFORE the send click.
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 200_000)
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).
await page.locator('[data-streaming="true"]').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {
// Chunks may coalesce into one commit; a never-mounted streaming node is
// legal — the chunk-event assertions below carry incrementality.
})
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// World state, not self-report: the real bash executor returned the exact
// command output, and the turn closed cleanly.
const bashCall = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'bash')
if (bashCall?.type !== 'tool/call') throw new Error('the replayed turn did not call the bash tool')
const bashResult = sessionEvents.find(event =>
event.type === 'tool/result' && event.data.callId === bashCall.data.callId)
if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result')
expect(bashResult.data.isError).toBe(false)
expect(bashResult.data.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe('WEB_E2E_OK\n')
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
expect(turnEnds.length).toBe(1)
expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
// The persisted chunk events are the authoritative incrementality proof.
expect(sessionEvents.filter(e => e.type === 'assistant/chunk').length).toBeGreaterThan(10)
}, 60_000)
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-aria'))
// Anchor assertions survive a semantics-preserving component rewrite even
// while the whole-region golden churns.
await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true)
expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
// Interaction over the REAL wire-delivered transcript (the fixture-client
// tier pins the same gesture against FixtureApiClient; this one runs on
// mux-frame-fed state). Runs after the golden capture so the committed
// aria surface stays the untouched settled state.
const think = page.getByRole('button', { name: /^Think/ }).first()
expect(await think.getAttribute('aria-expanded')).toBe('false')
await think.click()
await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
await think.click()
await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
})
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'])
})
})

474
apps/web/tests/scaffold.ts Normal file
View File

@@ -0,0 +1,474 @@
// Shared scaffold for the keyless browser e2e lane (Agent Note:
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
// Boots the REAL web composition — the shipped apps/cli/cordis.yml through
// 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).
//
// Composition divergences from `dsh web`, all deliberate, all via include
// patches over the SAME tree (never a second yml): 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
// (the plugin-row path discards the ReplayHandle; the direct install keeps
// assertConsumed for the teardown fixture-consumption check).
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Page } from 'playwright'
import { expect } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import SessionStore, {
packChunkRuns,
SESSION_FORMAT_VERSION,
SessionId,
type Session,
type SessionEvent,
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
// 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 { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
/**
* Resolve and validate the lane's snapshot mode.
* @returns the active mode; unset/empty selects replay.
*/
export function webSnapshotMode(): WebSnapshotMode {
const value = process.env.DSH_SNAPSHOT
if (value === undefined || value === '' || value === 'replay') return 'replay'
if (value === 'record' || value === 'refresh') return value
throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
}
/** The shipped composition under test: apps/cli's config tree. */
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
// Replay publishes the provider catalog the gateway routes to (providers
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
// catch-all would leave resolveModelContext unroutable and compact-basic's
// 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', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */
function loadRootEnv(): void {
const envPath = join(REPO_ROOT, '.env')
if (!existsSync(envPath)) return
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
}
}
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
mode: WebSnapshotMode
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
baseUrl: string
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
ctx: Context
/** Temp project directory sessions run in (bash/fs tool cwd). */
workspaceCwd: string
/** Temp persistence root (seeded sessions land here through the real API). */
persistenceRoot: 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). */
close(): Promise<void>
}
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/**
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
* in replay/refresh modes; ignored in record mode (the real adapter
* answers). Omit for scenarios issuing no model calls — a stray stream then
* fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
* mounts).
*/
replayFixture?: string
/**
* Optional replay.override.json sidecar (whole-script replacement or
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
* recorded chunks; replay/refresh only.
*/
replayOverride?: string
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
paceMs?: number
/**
* Tool presentation mode patched onto the shipped `tools` row (`code`
* collapses the wire to run_code + the SDK prompt section). Omit for the
* yml default. The code runtime row is always in the tree, so no extra
* insertion is needed.
*/
toolsMode?: 'native' | 'code' | 'both'
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise<unknown[]> {
const failures: unknown[] = []
await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error))
await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
return failures
}
/**
* Boot the real web composition under the current snapshot mode.
* @param options - replay fixture selection and pacing.
* @returns the running scaffold.
*/
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
requireDist()
const mode = webSnapshotMode()
if (mode === 'record') {
loadRootEnv()
if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) {
throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
}
}
const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
let persistenceRoot: string
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
} catch (error) {
const failures: unknown[] = [error]
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
// 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 patches: PatchOptions[] = [
{ 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: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
// 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 },
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }],
]
// Sessions inherit the gateway's process.cwd() default; run the boot from
// the temp workspace so tool cwd, session cwd, and fixtures agree.
const originalCwd = process.cwd()
const ctx = new Context()
let port = 0
let replayHandle: ReplayHandle | undefined
try {
process.chdir(workspaceCwd)
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
})
await ctx.loader.await()
assertEntriesLoaded(ctx, 'web e2e scaffold')
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.
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}
} catch (error) {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
}
throw error
} finally {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
}
return {
mode,
baseUrl: `http://127.0.0.1:${port}`,
ctx,
workspaceCwd,
persistenceRoot,
// Barrier stack: the in-process turn/end identifies the session, then
// agent.whenIdle() covers the persistence flush (the idle flip follows
// the flush), and the caller's browser settled-poll comes last because
// host completion strictly precedes render.
whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise<SessionId> {
return new Promise<SessionId>((resolveSettled, reject) => {
const timer = setTimeout(() => {
off()
reject(new Error(`no turn/end within ${timeoutMs}ms`))
}, timeoutMs)
const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
if (event.type !== 'turn/end') return
clearTimeout(timer)
off()
const agent = ctx.agents.get(session.id)
if (agent === undefined) {
reject(new Error(`turn/end for ${session.id} but no live agent`))
return
}
agent.whenIdle().then(() => { resolveSettled(session.id) }, reject)
})
})
},
async close(): Promise<void> {
const failures: unknown[] = []
// Fixture-consumption check first, while the run's binding state is
// still authoritative — a scenario that drove fewer model calls than
// recorded fails here instead of drifting green.
try {
replayHandle?.assertConsumed()
} catch (error) {
failures.push(error)
}
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
},
}
}
/**
* Serialize a live session to the canonical raw session-JSONL layout — the
* in-memory record-mode harvest, so the on-disk zstd default never matters.
*/
function rawSessionLog(session: Session): string {
return [
JSON.stringify({ type: 'session', ...session.header }),
...packChunkRuns(session.events).map(record => JSON.stringify(record)),
'',
].join('\n')
}
/**
* Record-mode fixture write-back: harvest the live session, scrub request
* headers to {{system}}/{{tools}} (TODO(web-header-pin): the web lane pins no
* header class — a deliberate deviation logged in the Agent Note's deferred
* work), tokenize the run-local session id, cwd, and browser RPC id
* ({{sessionId}}/{{cwd}}/{{rpcId}}, the committed fixture convention —
* re-records then diff only on real content), and write the fixture.
* @param scaffold - the record-mode scaffold.
* @param sessionId - the driven session.
* @param fixturePath - the committed session.jsonl / seed.jsonl target.
*/
export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise<void> {
const agent = scaffold.ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
.split(sessionId).join('{{sessionId}}')
.split(scaffold.workspaceCwd).join('{{cwd}}')
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
await writeFile(fixturePath, tokenized)
}
/**
* The user prompts recorded in a fixture, in order — the single source tying
* spec drive steps to recorded reality so script and fixture cannot drift.
* @param fixtureText - raw session.jsonl contents.
* @returns the recorded user prompt texts.
*/
export function fixtureUserPrompts(fixtureText: string): string[] {
return parseSessionLog(fixtureText).flatMap((event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
return text.length > 0 ? [text] : []
})
}
/**
* Seed a recorded session fixture into the scaffold's persistence root
* through the REAL backend API (throwaway Context + SessionStore + JSONL
* plugin — the semantic-checkpoint precedent), never raw file writes: no
* knowledge of bucket hashing, filename encoding, or compression, and
* malformed shapes fail loud at seed time. The fixture's tokenized identity
* ({{sessionId}}/{{cwd}}) is realized for this world before parsing.
* @param scaffold - the target scaffold.
* @param fixtureText - raw recorded session.jsonl contents.
* @param id - the seeded session id (stable for deterministic goldens).
* @returns the seeded id.
*/
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
const realized = fixtureText
.split('{{sessionId}}').join(id)
.split('{{cwd}}').join(scaffold.workspaceCwd)
const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
const rewritten = fixtureCwd === undefined
? realized
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
const events = parseSessionLog(rewritten)
if (events.length === 0) throw new Error('seed fixture has no events')
const last = events[events.length - 1]!
// An open final turn would be mutated by resume's crash repair on first
// open; a committed seed must be a closed recording.
if (last.type !== 'turn/end') throw new Error(`seed fixture must end in turn/end, got ${last.type}`)
const meta: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: SessionId(id),
createdAt: Date.now() - 60_000,
cwd: scaffold.workspaceCwd,
delegationDepth: 0,
}
const seeder = new Context()
try {
await seeder.plugin(SessionStore)
// Same root as the booted tree with the plugin's own default compression,
// so the host's directory-scan list() sees one consistent encoding.
await seeder.plugin(SessionPersistenceJsonl, { root: scaffold.persistenceRoot })
await seeder.sessionPersistence.create(meta)
await seeder.sessionPersistence.append(meta.id, events)
// Deterministic sidebar order: cold summaries take updatedAt from mtime.
const located = seeder.sessionPersistence.locate(meta)
if (located !== undefined) {
const backdated = new Date(meta.createdAt)
await utimes(located.path, backdated, backdated)
}
} finally {
await seeder.fiber.dispose()
}
return meta.id
}
/**
* Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration
* volatility collapse to stable tokens.
*/
function normalizeAria(snapshot: string, workspaceCwd: string): string {
// The header breadcrumb 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}}')
}
/**
* Capture the region's aria snapshot at a settled milestone: poll until two
* consecutive normalized captures are equal — a single-shot capture races the
* last React commits.
* @param page - the page under test.
* @param selector - the region locator selector.
* @param workspaceCwd - normalization input.
* @returns the stable normalized snapshot.
*/
export async function captureStableAria(page: Page, selector: string, workspaceCwd: string): Promise<string> {
const region = page.locator(selector).first()
let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
await expect.poll(async () => {
const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
const stable = current === previous
previous = current
return stable
}, { timeout: 5_000, message: 'aria snapshot did not stabilize' }).toBe(true)
return previous
}
/**
* Compare a normalized golden, or rewrite it under refresh. Refresh is the
* ONLY writer: a missing golden in replay mode fails with the healing command
* instead of silently self-bootstrapping.
* @param goldenPath - the committed ui.expected.md path.
* @param actual - the stable normalized snapshot.
* @param mode - the active snapshot mode.
*/
export async function compareOrRefreshGolden(goldenPath: string, actual: string, mode: WebSnapshotMode): Promise<void> {
const payload = `${actual}\n`
if (mode === 'refresh') {
await writeFile(goldenPath, payload)
return
}
if (!existsSync(goldenPath)) {
throw new Error(`missing golden ${goldenPath} — run DSH_SNAPSHOT=refresh pnpm run test:web to generate it`)
}
expect(payload).toBe(await readFile(goldenPath, 'utf8'))
}
/**
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
* holds exactly the expected files and every committed JSONL is a scrub
* fixed-point without a run-local browser RPC id.
* @param dir - the scenario snapshot directory.
* @param expected - the exact expected file inventory.
*/
export async function assertFixtureInventory(dir: string, expected: string[]): Promise<void> {
const entries = (await readdir(dir)).sort()
expect(entries).toEqual([...expected].sort())
for (const entry of entries.filter(name => name.endsWith('.jsonl'))) {
const content = await readFile(join(dir, entry), 'utf8')
expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content)
expect(content, `${dir}/${entry} carries a run-local rpcId`)
.not.toMatch(/"rpcId":"(?!\{\{rpcId\}\})[^"]+"/)
}
}
/**
* Console tripwires: reconnect/gap-repair self-healing or a pageerror must
* fail the scenario, not mask a dead wire behind eventual consistency.
* @param page - the page under test.
* @returns live warning/pageerror collectors to assert empty at scenario end.
*/
export function watchConsole(page: Page): { warnings: string[]; pageErrors: string[] } {
const warnings: string[] = []
const pageErrors: string[] = []
page.on('console', (message) => {
const text = message.text()
if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text)
})
page.on('pageerror', (error) => { pageErrors.push(String(error)) })
return { warnings, pageErrors }
}
/**
* Remove only connection-loss warnings emitted after an intentional reload.
* Earlier warnings and all gap-repair/discontinuity warnings remain fatal.
* @param tripwire - the live console-warning collector.
* @param warningStart - warning count captured immediately before reloading.
*/
export function acknowledgeReloadConnectionLoss(
tripwire: ReturnType<typeof watchConsole>,
warningStart: number,
): void {
const reloadWarnings = tripwire.warnings.splice(warningStart)
tripwire.warnings.push(...reloadWarnings.filter(text => !/connection lost/i.test(text)))
}

View File

@@ -0,0 +1,124 @@
// 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
// 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
// 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.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { 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))
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.'
describe('web e2e: seeded history renders through cold resume', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/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.
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
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)
}
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record'))
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
await recordFixture(scaffold, sessionId, SEED)
}, 200_000)
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history'))
// The sidebar tree collapses workspace groups by default: click the group
// row (treeitem 0) to expand, then 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()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_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)
}, 60_000)
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
// Interaction over cold-resumed history: read rows are expand-in-place
// rows (rowExpands routes the click to toggleExpand, not openDetails), so
// the gesture under test is the inline fold over log-rebuilt content.
// Runs after the golden capture; still zero model calls.
const row = page.locator('[data-variant] [data-clickable][role="button"]').first()
await row.waitFor({ timeout: 10_000 })
expect(await row.getAttribute('aria-expanded')).toBe('false')
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
// The expanded body renders the recorded tool result (a.txt's contents).
await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).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'])
})
})

View File

@@ -10,9 +10,12 @@ 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-i18n', dir: 'i18n', url: '/plugins/i18n.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-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
{ id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
{ id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
{ 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'] },
@@ -94,10 +97,10 @@ it('projects initial and revised durable titles through the built nine-plugin fi
})
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const projectCount = await within(tree).findByText('4 sessions')
const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]')
if (projectRow === null) throw new Error('fixture project row missing')
fireEvent.click(projectRow)
// The fixture Intent selects the workspace, so the current-group effect
// already expanded it; clicking the header would now collapse (the twist
// stays live since intent stopped forcing expansion).
await within(tree).findByText('4 sessions')
const initialLabel = 'Fixture 历史会话'
const initialRowLabel = await screen.findByText(initialLabel)

View File

@@ -0,0 +1,170 @@
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// and the Language row (settings-scoped localization + persisted dsh.locale).
// 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 { 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 {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { 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', () => {
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 browser.newPage({ viewport: { width: 1680, height: 1000 } })
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 settings dialog, switches sections, and closes by every path', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell'))
const trigger = page.getByRole('button', { name: '设置', exact: true })
expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog')
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
await trigger.click()
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.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
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.
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()
// Close path 1: Escape.
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
// Close path 2: the header close button (focus lands there on open).
await trigger.click()
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
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 }> =>
await page.evaluate(() => ({
attr: document.body.hasAttribute('data-ds-dark-theme'),
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
stored: localStorage.getItem('dsh.theme'),
}))
// Pin the OS scheme to light so the default `system` preference resolves
// light and the dark flip below is unambiguously the gesture's doing.
await page.emulateMedia({ colorScheme: 'light' })
const light = await readState()
expect(light.attr).toBe(false)
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
const darkCube = dialog.getByRole('button', { name: '深色' })
expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
await darkCube.click()
// The full cascade: pressed state, persisted preference, body attribute,
// alias token flip — all from one real user gesture.
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
const dark = await readState()
expect(dark.attr).toBe(true)
expect(dark.stored).toBe('dark')
expect(dark.token).not.toBe(light.token)
await page.keyboard.press('Escape')
// Reload: the preference survives boot (restore + presenter initial apply).
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.emulateMedia({ colorScheme: 'light' })
const reloaded = await readState()
expect(reloaded.attr).toBe(true)
expect(reloaded.stored).toBe('dark')
// `system` follows the emulated OS scheme (dark stays dark, light clears).
await page.getByRole('button', { name: '设置', exact: true }).click()
const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
// Restore for the specs that follow: light preference beats the emulated
// dark OS scheme, leaving the shared page in the light default.
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
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()
const zhDialog = page.getByRole('dialog', { name: '设置' })
await zhDialog.waitFor({ timeout: 10_000 })
// The Language selector pill shows the active locale's own name.
const selector = zhDialog.getByRole('button', { name: '中文' })
expect(await selector.getAttribute('aria-haspopup')).toBe('menu')
await selector.click()
await page.getByRole('menuitem', { name: 'English' }).click()
// The settings-owned copy re-registers localized: dialog title, nav,
// Appearance labels. (Only the settings namespaces are localized today —
// the rest of the app's copy is intentionally out of this row's scope.)
const enDialog = page.getByRole('dialog', { name: 'Settings' })
await enDialog.waitFor({ timeout: 10_000 })
expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en')
// Reload keeps English; then restore zh so shared page state (and the
// other specs' 设置-anchored selectors + goldens) see the default again.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
const enTrigger = page.getByRole('button', { name: 'Settings' })
await enTrigger.waitFor({ timeout: 10_000 })
await enTrigger.click()
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
await page.getByRole('menuitem', { name: '中文' }).click()
await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh')
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 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,191 @@
// @vitest-environment jsdom
// Assembled keyless snapshot of the slash/input/session convergence under the
// agent-parity model: the New Session view state locks the composer until a
// Workspace is picked (connectWorkspace materializes the full Session+Agent),
// the '/' menu serves the session's wire command catalog (sessions are always
// agent-backed — no draft/materialized split), a leadingInput command claims,
// submits over the wire, and notices its result, and the SAME composer
// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
// flips blank and surfaces the session in lists. This is the user-visible
// acceptance anchor — package mocks do not substitute for the assembled
// application transcript.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
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-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
{ id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
{
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',
],
},
]
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
beforeEach(() => {
localStorage.clear()
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__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
vi.unstubAllGlobals()
})
/** Boot the complete built client graph against one keyless fixture branch. */
function boot(search: string): void {
history.replaceState(null, '', `/${search}`)
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() }
})
}
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** Type into the machine-driven composer and let the change echo back. */
async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise<void> {
fireEvent.change(composer, { target: { value } })
await waitFor(() => { expect(composer.value).toBe(value) })
}
it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
boot('?fixture=empty')
// View state: no session entity — the composer renders locked; only the
// workspace picker is live.
const locked = await screen.findByPlaceholderText<HTMLTextAreaElement>(
'Choose a workspace to start', {}, { timeout: 10_000 },
)
expect(locked.disabled).toBe(true)
// Pick (create) a Workspace: connectWorkspace materializes the full
// Session+Agent and the provider swaps in the live blank-session hero.
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
target: { value: 'nova' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
const composer = await screen.findByPlaceholderText<HTMLTextAreaElement>(
'Describe what you want to build', {}, { timeout: 10_000 },
)
expect(composer.disabled).toBe(false)
// '/' opens the menu with the session's wire command catalog (the session
// is agent-backed from birth — the catalog is the single-address list).
await typeComposer(composer, '/')
const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
const menuText = visibleText(menu)
// Pick /echo (leadingInput): the claim token lands in the same textarea.
fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
await waitFor(() => { expect(composer.value).toBe('/echo ') })
// Type args and submit: the claim executes over the wire and notices its
// result; the token is consumed and the draft returns to plain text.
await typeComposer(composer, '/echo hello parser')
fireEvent.keyDown(composer, { key: 'Enter' })
await screen.findByText('hello parser', {}, { timeout: 10_000 })
await waitFor(() => { expect(composer.value).toBe('') })
// Slash execution does not flip blank: the selected row remains New Session.
const tree = screen.getByRole('tree', { name: 'Sessions' })
expect(within(tree).getByText('1 session')).toBeDefined()
expect(within(tree).getByText('New Session')).toBeDefined()
// First plain send through the SAME textarea: acceptance logs the user
// message and converts the existing sidebar row out of blank.
const before = composer
await typeComposer(composer, 'build me a parser')
fireEvent.keyDown(composer, { key: 'Enter' })
await waitFor(() => {
expect(screen.queryByText("Let's start building")).toBeNull()
}, { timeout: 10_000 })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
const after = document.querySelector('textarea')
expect({
menuHadEcho: menuText.includes('echo'),
menuHadCompact: menuText.includes('compact'),
composerSurvivedConversion: after === before,
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
}).toMatchInlineSnapshot(`
{
"composerSurvivedConversion": true,
"menuHadCompact": true,
"menuHadEcho": true,
"sessionListed": "nova1 session",
}
`)
})

View File

@@ -24,7 +24,7 @@ import { 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, probeFreePort, requireDist, saveFailureShot } from './support.ts'
import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
/** Repo-root .env → process.env (never overrides an already-set variable). */
function loadRootEnv(): void {
@@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise<number> {
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-settings', 'ui-settings-general', 'ui-models', 'ui-conversation', 'ui-question', 'ui-trajectory']
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
@@ -271,6 +271,83 @@ describe('dsh web keyless CLI smoke', () => {
rmSync(workspace, { recursive: true, force: true })
}
})
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-'))
interface CodeModeProviderRequest {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
}
let resolveProviderRequest!: (request: CodeModeProviderRequest) => void
const providerRequest = new Promise<CodeModeProviderRequest>((resolve) => {
resolveProviderRequest = resolve
})
const provider = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as CodeModeProviderRequest)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'data: {"choices":[{"delta":{"content":"done"}}]}',
'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-code-mode',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_TOOLS_MODE: 'code',
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: 'go' }],
})
const captured = await Promise.race([
providerRequest,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
}),
])
expect(captured.tools?.map(tool => tool.function?.name)).toEqual(['run_code'])
const system = captured.messages?.find(message => message.role === 'system')
expect(system?.content).toContain('## Writing code for run_code')
expect(system?.content).toContain('declare const tools')
} 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 })
}
})
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
@@ -327,6 +404,8 @@ 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'))
// Fresh world: connect a Workspace so the composer starts live.
await connectFreshWorkspace(page)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')

View File

@@ -0,0 +1,35 @@
{"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}}"}},"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":"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"}}}
{"type":"tool-call-chunks","seq0":82,"time0":1785013632220,"data":{"turn":1,"step":1,"index":1,"dt":[26,0,1,27,0,0,1,22,0,26,1,41,11,0,0,26,0,1,26,0,0,0,0,26,0,0,0,0,1,25,1,0,0,0,0,27,1,0,0,0,0,25,1,0,0,0,25,0,0,0,1,0,28,1,0,0,45,0,0,15,0,0,0,66,1,0,0,1,0,0,0,0,0,12,0,0,0,0,30,1,0,0,0,0,21,1,0,26,0,0,0,0,0,26,27,0,0,0,0,1,25,1,0,0,0,25,1,25,0,0,27,26,26,0,1,0,26,0],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]}}
{"type":"assistant/chunk","seq":201,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"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":"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,"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","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,"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"}}}
{"type":"reasoning-chunks","seq0":215,"time0":1785013633986,"data":{"turn":1,"step":2,"index":0,"dt":[106,27,1,0,0,23,1,29,0,1,25,1,22],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]}}
{"type":"assistant/chunk","seq":229,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":230,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":231,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":232,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."}}}}
{"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,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","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

@@ -0,0 +1,29 @@
- banner:
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- 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."
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- text: "Think The user wants me to write a single `run_code` program that:"
- button:
- img
- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK
- button
- text: Read missing.txt
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- text: Think The program ran successfully. Let me now reply DONE as instructed.
- paragraph: DONE
- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,31 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784973850888,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1784973850889,"data":{"turn":1,"step":1,"index":0,"dt":[199,1,0,0,0,18,1,0,0,0,0,27,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":23,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":24,"time0":1784973851217,"data":{"turn":1,"step":1,"index":1,"dt":[27,0,0,0,1,26,0,0,1,27,0,0,1,26,0,26,1,0,0,26,27,0,29,0,0,26],"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," WEB","_E","2","E","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," the"," test"," string","\"","}"]}}
{"type":"assistant/chunk","seq":51,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"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,50,51,52,53,54],"surfaceOp":"append"}
{"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":61,"time0":1784973852195,"data":{"turn":1,"step":2,"index":0,"dt":[114,29,1,0,0,31,0,1,0,0,0,27,0,0,0,30,1,0,0,0,0,30],"texts":["The"," command"," executed"," successfully"," and"," output"," \"","WEB","_E","2","E","_OK","\"."," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":84,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":85,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":86,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":87,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}}
{"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"}
{"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,25 @@
- banner:
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- text: Think The user wants me to run a simple bash command and reply with "DONE".
- text: Echo the test string
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
- img
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
- paragraph: DONE
- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,36 @@
- button "Collapse sidebar":
- img
- button "New session":
- img
- text: New Session
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- button "设置":
- img
- text: 设置
- text: Let's start building
- button "Choose workspace":
- img
- text: workspace
- img
- textbox "Describe what you want to build"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]
- text: 详情
- button "关闭详情"
- text: 点击消息流中的工具行查看详情

View File

@@ -0,0 +1,21 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with the single word LIGHTHOUSE and stop.
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- text: Think The user wants me to reply with a single word. Let me comply.
- paragraph: LIGHTHOUSE
- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,17 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}}
{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}}
{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}}
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,18 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- paragraph: partial
- text: 已停止 0 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,16 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,21 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- text: cache hit 99% · 7,869 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,17 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784998084441,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1784998084454,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784998084457,"data":{"title":"Reply with a one-sentence description","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784998084519,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784998084900,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1784998084900,"data":{"turn":1,"step":1,"index":0,"dt":[153,3,0,29,1,0,0,28,0,1,0,0,0,28,0,0,0,29,1,0,29,0,0,29,0,0,36,0,21,29],"texts":["The"," user"," is"," asking"," for"," a"," one","-s","entence"," description"," of"," event"," sourcing","."," This"," is"," a"," straightforward"," knowledge"," question"," that"," doesn","'t"," require"," any"," skill"," loading"," or"," tool"," calls","."]}}
{"type":"assistant/chunk","seq":37,"time":1784998085318,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":38,"time0":1784998085318,"data":{"turn":1,"step":1,"index":1,"dt":[0,28,0,0,29,1,28,29,1,0,0,33,0,0,28,0,25,0,1,29,1,0,0,0,0,28,0,30,0,0,0,29,1,27,0,29,1,30,0,28,0,0,0,28,0,31],"texts":["Event"," sourcing"," is"," a"," pattern"," where"," all"," changes"," to"," an"," application","'s"," state"," are"," stored"," as"," an"," immutable",","," append","-only"," sequence"," of"," events",","," rather"," than"," pers","isting"," only"," the"," current"," state",","," enabling"," full"," audit","ability",","," temporal"," queries",","," and"," event","-driven"," architectures","."]}}
{"type":"assistant/chunk","seq":85,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."}}}}
{"type":"assistant/chunk","seq":86,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}}
{"type":"assistant/chunk","seq":87,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}}
{"type":"assistant/chunk","seq":88,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}},"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],"surfaceOp":"append"}
{"type":"step/end","seq":90,"time":1784998085820,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":91,"time":1784998085821,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,5 @@
- text: bash
- button "关闭详情"
- text: Input
- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
- text: Output NAVIGATION_OK

View File

@@ -0,0 +1,54 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785011380476,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785011380489,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785011380492,"data":{"title":"NavScenario: first run bash to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785011380549,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785011380550,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785011380917,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785011380917,"data":{"turn":1,"step":1,"index":0,"dt":[110,25,1,0,0,25,1,0,26,0,1,0,27,1,0,0,0,26,1,0,0,26,0,0,0,0,1,25,0,0,25,0,1,0,0,0,26,1,0,25,0,0,27,1,0,0,0,0,25,28,0,1,0,0,0,27,0,0,0,25,1,24,1,0,25],"texts":["The"," user"," wants"," me"," to"," follow"," a"," specific"," navigation"," scenario","."," Let"," me",":\n\n","1","."," Run"," bash"," to"," print"," \"","NA","V","IG","ATION","_OK","\"\n","2","."," Read"," nav","-a",".md"," and"," nav","-b",".md"," in"," two"," read"," calls"," in"," ONE"," message","\n","3","."," Reply"," with"," \"","FIR","ST","_D","ONE","\"\n\n","Let"," me"," start"," with"," the"," bash"," command"," and"," the"," reads","."]}}
{"type":"assistant/chunk","seq":72,"time":1785011381556,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":73,"time0":1785011381557,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,26,0,0,0,25,1,26,0,0,1,33,1,17,0,0,0,28,1,0,0,0,24,1],"id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," NAV","IG","ATION","_OK","\"",", ","\"","description","\"",": ","\"","Print"," NAV","IG","ATION","_OK","\"","}"]}}
{"type":"assistant/chunk","seq":99,"time":1785011381793,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":100,"time0":1785011381793,"data":{"turn":1,"step":1,"index":2,"dt":[26,0,0,0,1,27,0,0,0,26,1,23],"id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","args":["","{","\"","file","_path","\"",": ","\"","nav","-a",".md","\"","}"]}}
{"type":"assistant/chunk","seq":113,"time":1785011381924,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":3,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":114,"time0":1785011381924,"data":{"turn":1,"step":1,"index":3,"dt":[26,1,0,26,0,0,0,26,0,0,0,26],"id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","args":["","{","\"","file","_path","\"",": ","\"","nav","-b",".md","\"","}"]}}
{"type":"assistant/chunk","seq":127,"time":1785011382086,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."}}}}
{"type":"assistant/chunk","seq":128,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}}}}
{"type":"assistant/chunk","seq":129,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}}}}
{"type":"assistant/chunk","seq":130,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}}}
{"type":"assistant/chunk","seq":131,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}}}}
{"type":"assistant/chunk","seq":132,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":134,"time":1785011382092,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}}
{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"}
{"type":"tool/call","seq":136,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}}
{"type":"tool/call","seq":137,"time":1785011382106,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}
{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-a.md</path>\n<type>file</type>\n<content>\n1: # alpha nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[136],"surfaceOp":"append"}
{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-b.md</path>\n<type>file</type>\n<content>\n1: # beta nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[137],"surfaceOp":"append"}
{"type":"step/end","seq":140,"time":1785011382117,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":141,"time":1785011382118,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":142,"time":1785011382673,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":143,"time0":1785011382673,"data":{"turn":1,"step":2,"index":0,"dt":[90,27,27,1,0,0,0,26,1,0,0,0,0,27,0,0,0,0,27,0,0,27,0,0,0,0,1,25,1,0,0,0,0,26,1,0,0,0,0,25,1,0,26,1,0,0,0,0,26,1],"texts":["All"," three"," calls"," succeeded",":\n","1","."," bash"," printed"," \"","NA","V","IG","ATION","_OK","\"\n","2","."," nav","-a",".md"," contains"," \"#"," alpha"," nav","\"\n","3","."," nav","-b",".md"," contains"," \"#"," beta"," nav","\"\n\n","Now"," I"," need"," to"," reply"," with"," the"," single"," word"," \"","FIR","ST","_D","ONE","\"."]}}
{"type":"assistant/chunk","seq":194,"time":1785011383060,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":195,"time0":1785011383060,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,29],"texts":["FIR","ST","_D","ONE"]}}
{"type":"assistant/chunk","seq":199,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."}}}}
{"type":"assistant/chunk","seq":200,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST_DONE"}}}}
{"type":"assistant/chunk","seq":201,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}}}}
{"type":"assistant/chunk","seq":202,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[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],"surfaceOp":"append"}
{"type":"step/end","seq":204,"time":1785011383091,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":205,"time":1785011383092,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"turn/start","seq":206,"time":1785011383106,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"step/start","seq":208,"time":1785011383107,"data":{"turn":2,"step":1}}
{"type":"assistant/chunk","seq":209,"time":1785011383497,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":210,"time0":1785011383497,"data":{"turn":2,"step":1,"index":0,"dt":[125,23,1,0,0,88,0,0,5,0,1,0,0,7,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," specific"," format","."," Let"," me"," do"," that","."]}}
{"type":"assistant/chunk","seq":226,"time":1785011383748,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":227,"time0":1785011383748,"data":{"turn":2,"step":1,"index":1,"dt":[24,1,25,0,0,25,26,1,0,0,0,25,0,0,0,1,26,1],"texts":["##"," Navigation"," Summary","\n\n","-"," alpha"," nav","\n","-"," beta"," nav","\n\n","```\n","echo"," WATER","F","ALL","\n","```"]}}
{"type":"assistant/chunk","seq":246,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."}}}}
{"type":"assistant/chunk","seq":247,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}}}}
{"type":"assistant/chunk","seq":248,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}}}}
{"type":"assistant/chunk","seq":249,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}},"sourceEventSeqs":[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],"surfaceOp":"append"}
{"type":"step/end","seq":251,"time":1785011383904,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":252,"time":1785011383904,"data":{"turn":2,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1 @@
- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}"

View File

@@ -0,0 +1 @@
- text: 3 turns · 3 steps · 3 tool calls turn 0 turn 1 turn 2

View File

@@ -0,0 +1,27 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.
- button "Think The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that.":
- img
- text: Think The user wants me to use the ask_user_question tool to ask a specific question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". Let me do exactly that.
- button:
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"
- button "Think The user answered \"Blue\". I need to reply with the single word DONE and stop.":
- img
- text: Think The user answered "Blue". I need to reply with the single word DONE and stop.
- paragraph: DONE
- text: cache hit 99% · 15,978 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,31 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785001700711,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785001700724,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785001700725,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785001700727,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785001700783,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785001700784,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785001701372,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785001701373,"data":{"turn":1,"step":1,"index":0,"dt":[117,23,0,0,0,1,26,1,0,0,0,0,25,0,0,0,27,1,24,1,0,0,0,27,0,0,0,0,1,34,0,0,0,0,1,17,0,0,0,1,0,27,1,0,0,28,0,0,22,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," to"," ask"," a"," specific"," question"," with"," id"," \"","color","\","," question"," \"","Which"," color"," do"," you"," prefer","?\","," header"," \"","Pick"," one","\","," and"," options"," labeled"," \"","Blue","\""," and"," \"","Green","\"."," Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":57,"time":1785001701858,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":58,"time0":1785001701858,"data":{"turn":1,"step":1,"index":1,"dt":[27,1,0,0,0,24,1,0,0,28,0,0,0,0,1,24,0,0,1,0,0,26,0,0,0,0,0,26,0,1,0,0,0,25,0,0,0,0,3,23,1,0,0,0,0,26,1,26],"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\"},"," {\"","label","\":"," \"","Green","\"","}]","}]","}"]}}
{"type":"assistant/chunk","seq":107,"time":1785001702154,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."}}}}
{"type":"assistant/chunk","seq":108,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}}}}
{"type":"assistant/chunk","seq":109,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}}}}
{"type":"assistant/chunk","seq":110,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":111,"time":1785001702159,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."},{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":112,"time":1785001702160,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}}
{"type":"tool/result","seq":113,"time":1785001702566,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"}
{"type":"step/end","seq":114,"time":1785001702568,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":115,"time":1785001702569,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":116,"time":1785001702948,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":117,"time0":1785001702949,"data":{"turn":1,"step":2,"index":0,"dt":[84,26,1,0,0,0,0,29,0,0,22,0,1,0,0,0,27,1],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}}
{"type":"assistant/chunk","seq":136,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":137,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":138,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":139,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."}}}}
{"type":"assistant/chunk","seq":140,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":141,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}}}}
{"type":"assistant/chunk","seq":142,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":143,"time":1785001703141,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"}
{"type":"step/end","seq":144,"time":1785001703142,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":145,"time":1785001703142,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,23 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- text: 1 / 1
- button "上一题" [disabled]:
- img
- button "下一题" [disabled]:
- img
- button "放弃整组问题":
- img
- radiogroup:
- radio "Blue":
- text: 1 Blue
- img
- radio "Green":
- text: 2 Green
- img
- button "其他,请填写自定义答案":
- img
- text: 其他,请填写自定义答案
- status
- button "跳过本题"
- button "提交" [disabled]

View File

@@ -0,0 +1,36 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1784974101297,"data":{"turn":1,"step":1,"index":0,"dt":[125,30,0,1,0,0,30,1,0,0,0,0,30,1,0,0,30,0,0,0,0,1,30,1,0,0],"texts":["The"," user"," wants"," me"," to"," read"," a",".txt"," and"," b",".txt",","," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," do"," both"," reads"," in"," parallel","."]}}
{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":34,"time0":1784974101667,"data":{"turn":1,"step":1,"index":1,"dt":[30,0,0,0,0,0,29,1,0,29,1],"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","args":["","{","\"","file","_path","\"",": ","\"","a",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":47,"time0":1784974101822,"data":{"turn":1,"step":1,"index":2,"dt":[27,0,0,0,1,31,0,1,0,26,1],"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","args":["","{","\"","file","_path","\"",": ","\"","b",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}}
{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}}
{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}}
{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}
{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}
{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"<path>{{cwd}}/workspace/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"}
{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"<path>{{cwd}}/workspace/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"}
{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":72,"time0":1784974102397,"data":{"turn":1,"step":2,"index":0,"dt":[108,29,1,0,0,30,30,1,0,0,0,29,0,0,0,0,1,30,0,0,0,33,1,0,26,1,31,1],"texts":["Both"," files"," have"," been"," read","."," a",".txt"," contains"," \"","alpha","\""," and"," b",".txt"," contains"," \"","beta","\"."," I","'ll"," now"," reply"," with"," D","ONE"," as"," instructed","."]}}
{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":104,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."}}}}
{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}}
{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"}
{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,30 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- button:
- img
- text: Read a.txt
- button:
- img
- text: Read b.txt
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,30 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "关闭":
- img
- text: 关闭
- text: 权限 选择默认权限模式
- button "Read only" [disabled]:
- text: Read only
- img
- text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言
- button "中文":
- text: 中文
- img
- text: 外观
- button "浅色":
- img
- text: 浅色
- button "深色":
- img
- text: 深色
- button "跟随系统" [pressed]:
- img
- text: 跟随系统

View File

@@ -0,0 +1,39 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答1 题)"
- button "▸ 问题内容"
- text: 请在原客户端处理web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
- text: 1 / 1
- button "上一题" [disabled]:
- img
- button "下一题" [disabled]:
- img
- button "放弃整组问题":
- img
- radiogroup:
- radio "Yes":
- text: 1 Yes
- img
- radio "No":
- text: 2 No
- img
- button "其他,请填写自定义答案":
- img
- text: 其他,请填写自定义答案
- status
- button "跳过本题"
- button "提交" [disabled]

View File

@@ -0,0 +1,31 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785004180013,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785004180030,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785004180033,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785004180105,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785004180106,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785004180696,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785004180697,"data":{"turn":1,"step":1,"index":0,"dt":[88,29,1,0,0,0,28,0,0,1,0,0,30,1,27,0,0,0,0,28,1,0,30,0,0,0,28,1],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," to"," ask"," them"," a"," specific"," question"," with"," the"," given"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":35,"time":1785004181077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":36,"time0":1785004181078,"data":{"turn":1,"step":1,"index":1,"dt":[27,1,0,0,0,28,1,0,0,29,1,0,0,0,0,28,1,0,0,0,0,29,0,0,0,0,1,28,0,0,0,0,1,28,0,0,0,0,0,28,0,1,0,0,0,28,30],"id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","check","point","\","," \"","question","\":"," \"","Ready"," to"," continue","?\","," \"","header","\":"," \"","Check","point","\","," \"","options","\":"," [","{\"","label","\":"," \"","Yes","\"},"," {\"","label","\":"," \"","No","\"","}]","}]","}"]}}
{"type":"assistant/chunk","seq":84,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."}}}}
{"type":"assistant/chunk","seq":85,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}}}
{"type":"assistant/chunk","seq":86,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}}}}
{"type":"assistant/chunk","seq":87,"time":1785004181402,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}
{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":95,"time0":1785004182323,"data":{"turn":1,"step":2,"index":0,"dt":[129,28,1,0,0,28,1,29,0,0,30,1,27,0,0,0,29,1,0,0,0,0,28,1,0,29,64,0],"texts":["The"," user"," selected"," \"","Yes","\""," and"," wants"," me"," to"," include"," the"," word"," \"","B","AN","ANA","\""," in"," my"," final"," reply","."," Let"," me"," acknowledge"," their"," answer","."]}}
{"type":"assistant/chunk","seq":124,"time":1785004182750,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":125,"time0":1785004182750,"data":{"turn":1,"step":2,"index":1,"dt":[24,0,28,2,0,27,31,1,0,0],"texts":["Great",","," let","'s"," move"," forward","."," B","AN","ANA","!"]}}
{"type":"assistant/chunk","seq":136,"time":1785004182892,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."}}}}
{"type":"assistant/chunk","seq":137,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}}}
{"type":"assistant/chunk","seq":138,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}}}}
{"type":"assistant/chunk","seq":139,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"}
{"type":"step/end","seq":141,"time":1785004182895,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":142,"time":1785004182895,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,27 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button:
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
- paragraph: Great, let's move forward. BANANA!
- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "Send message" [disabled]

View File

@@ -0,0 +1,173 @@
// Web e2e scenario: mid-turn steering, end to end. The composer locks while a
// turn runs, so the product UI has no steering gesture yet — the steer is
// POSTed from the page itself over the same same-origin /api transport the
// client uses (TODO(web-steer-composer): drive this through a composer
// gesture once one exists). Everything downstream is product: the gateway
// routes mode:'steer' to Agent.steer, the loop drains it at the step
// boundary into a durable steering/message event, the SSE mux pushes it, and
// the transcript renders the badged interjection bubble. The question
// composer supplies the deterministic mid-turn window: while ask_user_question
// blocks, the turn is provably running, so record and replay perform the
// identical steer-then-answer sequence with zero timing dependence — and the
// recorded final reply proves the steer reached the MODEL (it obeys an
// instruction that only the steering message carries).
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 { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// Two goldens for the two distinct states this interaction produces: the
// mid-turn moment (steer ACCEPTED but deliberately invisible — the loop
// drains steering at the step boundary, so no interjection bubble exists
// while the question still blocks the step) and the settled transcript
// (badged bubble in place, final reply obeying it). The pair pins the
// timing semantics visually: if the client ever starts rendering pending
// steers eagerly, the mid-steer golden flips first.
const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
const STEER = 'Interjection: include the word BANANA in your final reply.'
/** Concatenated assistant text deltas — the model-visible reply body. */
function assistantText(events: SessionEvent[]): string {
return events
.filter(e => e.type === 'assistant/chunk')
.map((e) => {
const chunk = (e as SessionEvent & { data: { chunk: { type: string; text?: string } } }).data.chunk
return chunk.type === 'text-delta' ? chunk.text ?? '' : ''
})
.join('')
}
describe('web e2e: mid-turn steering lands durably and visibly', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let liveSessionId: string | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (session, event) => {
liveSessionId ??= session.id
sessionEvents.push(event)
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
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)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('steers during the blocked step; the interjection is logged, rendered, and obeyed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
if (MODE !== 'record') {
// The steer must NOT be a user/message — it lands as steering/message.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
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(PROMPT)
await input.press('Enter')
// The blocked composer is the mid-turn barrier: its presence proves the
// ask_user_question step is executing, i.e. the turn is running NOW.
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
// Steer through the real wire from the page (same envelope + endpoint the
// web client's session.prompt uses). accepted:true is the transport proof.
expect(liveSessionId).toBeDefined()
const reply = await page.evaluate(async ({ sessionId, text }) => {
const response = await fetch('/api/session.prompt', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: crypto.randomUUID(),
method: 'session.prompt',
payload: { sessionId, mode: 'steer', content: [{ type: 'text', text }] },
}),
})
return await response.json() as { result?: { ok?: boolean } }
}, { sessionId: liveSessionId!, text: STEER })
expect(reply.result?.ok).toBe(true)
if (MODE !== 'record') {
// Mid-turn golden: the ACCEPTED steer is durable in the inbox but the
// loop drains steering only at the step boundary, so no steering/message
// exists yet and no interjection bubble renders — the composer still
// blocks, alone. The DOM is stable here (no further SSE frames can
// arrive until the question is answered), making this state capturable.
expect(await page.getByText('插话').count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE)
}
// Answer the composer; the tool result closes the step, the loop drains
// the steer as steering/message, and the steered continuation runs the
// final model call.
await composer.getByRole('radio', { name: 'Yes' }).click()
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
await settled
if (MODE === 'record') {
const sessionId = await settled
await recordFixture(scaffold, sessionId, FIXTURE)
// Fixture honesty: a recording where the live model ignored the steer
// would replay as a vacuous scenario — reject it and re-record instead.
const recorded = parseSessionLog(await readFile(FIXTURE, 'utf8'))
expect(recorded.filter(e => e.type === 'steering/message')).toHaveLength(1)
expect(assistantText(recorded)).toContain('BANANA')
return
}
// Durable: exactly one steering/message, inside turn 1, carrying the text.
const steerEvents = sessionEvents.filter(e => e.type === 'steering/message')
expect(steerEvents).toHaveLength(1)
expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
expect(JSON.stringify(steerEvents[0])).toContain('BANANA')
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
expect(turnEnds).toHaveLength(1)
expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
// Visible: the badged interjection bubble plus the reply that obeys it
// (steer text + final reply each contain the marker word).
await expect.poll(() => page.getByText('插话').count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.locator('[data-question-key]').count()).toBe(0)
// Settled golden: badge + interjection between the question round trip
// and the obeying reply, composer takeover gone.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_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', 'mid-steer.expected.md', 'settled.expected.md'])
})
})

View File

@@ -32,6 +32,31 @@ export function probeFreePort(): Promise<number> {
})
}
/**
* Drive the hero's workspace picker through its create-by-name dialog until
* the live composer unlocks. A fresh world has no Workspace, so the boot
* lands in the locked view state (startup auto-selection has nothing to
* select); every scenario that types into the composer must connect one
* first. The default name 'workspace' keeps the session header cwd at
* <workspaceRoot>/workspace — the materialization proof several scenarios
* assert.
* @param page - the page under test.
* @param name - workspace name typed into the create dialog.
*/
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('New workspace name').fill(name)
await dialog.getByRole('button', { name: 'Create workspace' }).click()
// The pick connected the workspace: the blank session's live composer
// replaces the locked placeholder and enables.
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
.waitFor({ timeout: 15_000 })
}
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
export async function saveFailureShot(page: Page, name: string): Promise<void> {
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))

View File

@@ -1,4 +1,12 @@
// @vitest-environment jsdom
// Assembled keyless snapshots of the New Session flow under the agent-parity
// model: startup auto-connects the recent Workspace's blank session when one
// exists; without any Workspace the composer is locked in the pure view
// state until one is chosen. Picking one materializes the full Session+Agent
// (reuse-or-create of the workspace's blank session), the first ACCEPTED
// prompt flips blank and surfaces the session in lists, and failures leave
// no client-side transaction state: a failed attach keeps the view state
// locked, a rejected prompt keeps the session blank with the draft restored.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -10,9 +18,12 @@ 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-i18n', dir: 'i18n', url: '/plugins/i18n.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-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
{ id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
{ id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
{ 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',
@@ -90,25 +101,12 @@ function boot(search: string): void {
})
}
/** Recreate the built client graph while preserving browser-persistent state. */
function refresh(search: string): void {
act(() => { unmount?.() })
unmount = undefined
cleanup()
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
boot(search)
}
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** Identify the interactive Workspace chip by its menu contract. */
/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
function workspaceChip(): HTMLElement {
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
.find(element => element.getAttribute('aria-haspopup') === 'menu')
@@ -116,208 +114,242 @@ function workspaceChip(): HTMLElement {
return chip
}
/** Wait for the runtime-owned controlled input to echo a browser edit. */
async function setComposerText(composer: HTMLElement, value: string): Promise<void> {
fireEvent.change(composer, { target: { value } })
await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) })
/** The locked view-state composer (no session yet). */
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
return await screen.findByPlaceholderText(
'Choose a workspace to start', {}, { timeout: 10_000 },
)
}
it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
/** The live blank-session hero composer (session materialized). */
async function findHeroComposer(): Promise<HTMLTextAreaElement> {
return await screen.findByPlaceholderText(
'Describe what you want to build', {}, { timeout: 10_000 },
)
}
/** Edit the machine-owned controlled input and assert the same-tick echo. */
function setComposerText(composer: HTMLElement, value: string): void {
fireEvent.change(composer, { target: { value } })
expect((composer as HTMLTextAreaElement).value).toBe(value)
}
/** Drive the picker's create flow: chip → Create workspace → name dialog. */
async function createWorkspaceViaPicker(name: string): Promise<void> {
fireEvent.click(workspaceChip())
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
target: { value: name },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
}
/** Pick an existing Workspace row from the chip menu. */
async function pickWorkspace(title: string): Promise<void> {
fireEvent.click(workspaceChip())
fireEvent.click(await screen.findByRole('menuitem', { name: title }))
}
it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const composer = await findLockedComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
await setComposerText(composer, 'keep this local')
expect({
headline: visibleText(screen.getByText("Let's start building")),
workspaceDraft: visibleText(workspaceChip()),
chip: visibleText(workspaceChip()),
composerDisabled: composer.disabled,
sendDisabled: screen.getByRole<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
sidebar: visibleText(tree),
composerDisabled: (composer as HTMLTextAreaElement).disabled,
prompt: (composer as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"composerDisabled": false,
"chip": "New Workspace",
"composerDisabled": true,
"headline": "Let's start building",
"prompt": "keep this local",
"sendDisabled": true,
"sidebar": "No sessions yet",
"workspaceDraft": "workspace",
}
`)
})
it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
boot('?fixture=empty')
it('selects the recent Workspace and opens its blank Session on first load', async () => {
boot('?fixture')
await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const workspaceSection = screen.getByText('Workspaces').parentElement
if (workspaceSection === null) throw new Error('Workspace section missing')
fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
target: { value: 'nova' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
const tree = await screen.findByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
if (group === null || draft === null) throw new Error('created Workspace projection missing')
const composer = await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
expect({
workspace: visibleText(group),
draft: visibleText(draft),
draftSelected: draft.getAttribute('aria-selected'),
composerWorkspace: visibleText(workspaceChip()),
chip: visibleText(workspaceChip()),
composerDisabled: composer.disabled,
blankRow: within(tree).getByText('New Session').textContent,
}).toMatchInlineSnapshot(`
{
"composerWorkspace": "nova",
"draft": "New session",
"draftSelected": "true",
"blankRow": "New Session",
"chip": "fixture",
"composerDisabled": false,
}
`)
})
it('creating a Workspace materializes and lists its selected blank Session', async () => {
boot('?fixture=empty')
await findLockedComposer()
await createWorkspaceViaPicker('nova')
// The pick connected the workspace: full Session+Agent exists, composer live.
const composer = await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
expect(within(tree).getByText('New Session')).toBeDefined()
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
if (group === null) throw new Error('created Workspace projection missing')
expect({
composerDisabled: composer.disabled,
chip: visibleText(workspaceChip()),
workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
"chip": "nova",
"composerDisabled": false,
"workspace": "nova1 session",
}
`)
})
it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
boot('?fixture')
it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await findLockedComposer()
await createWorkspaceViaPicker('nova')
await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
await setComposerText(composer, 'discard this page-local draft')
const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
const before = {
workspace: visibleText(beforeGroup),
draft: visibleText(within(tree).getByText('New session')),
prompt: (composer as HTMLTextAreaElement).value,
}
// New Session resolves through the recent Workspace and reuses its blank
// session in place: no locked interlude, no second entity.
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
const composer = await findHeroComposer()
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
refresh('?fixture')
setComposerText(composer, 'first light')
fireEvent.keyDown(composer, { key: 'Enter' })
const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
// Conversion: the accepted prompt flips blank without adding a second row.
await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
if (group === null) throw new Error('converted Session projection missing')
expect({
before,
after: {
workspace: visibleText(afterGroup),
replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
prompt: (refreshedComposer as HTMLTextAreaElement).value,
},
workspace: visibleText(group),
promptVisible: screen.getByText('first light', { exact: true }).textContent,
}).toMatchInlineSnapshot(`
{
"after": {
"prompt": "",
"replacementDraft": "New session",
"workspace": "fixture4 sessions",
},
"before": {
"draft": "New session",
"prompt": "discard this page-local draft",
"workspace": "fixture4 sessions",
},
"promptVisible": "first light",
"workspace": "nova1 session",
}
`)
})
it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
it('a failed Workspace attach recovers by reusing the published blank session', async () => {
boot('?fixture&fixtureAttach=fail')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await setComposerText(composer, 'keep this cwd-only session')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
// The rejected startup connect surfaces the locked view state first: the
// failure leaves no client-side transaction state to unwind.
await findLockedComposer()
// The host published the session before rejecting attachment (blank, with
// the workspace cwd), so the next connect — retry or manual pick — reuses
// it instead of minting a duplicate, and the hero opens on it.
await pickWorkspace('fixture')
const composer = await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
const ungroupedSection = ungroupedGroup?.parentElement
if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
throw new Error('Workspace or Ungrouped projection missing')
}
const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
const retained = screen.getByDisplayValue('keep this cwd-only session')
const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
if (group === null) throw new Error('fixture Workspace projection missing')
expect({
workspace: visibleText(workspaceGroup),
ungrouped: visibleText(ungroupedGroup),
session: within(session).getByText('fixture', { exact: true }).textContent,
sessionSelected: session.getAttribute('aria-selected'),
prompt: (retained as HTMLTextAreaElement).value,
headline: visibleText(screen.getByText("Let's start building")),
composerDisabled: composer.disabled,
chip: visibleText(workspaceChip()),
workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
"prompt": "keep this cwd-only session",
"session": "fixture",
"sessionSelected": "true",
"ungrouped": "Ungrouped1 session",
"chip": "fixture",
"composerDisabled": false,
"headline": "Let's start building",
"workspace": "fixture3 sessions",
}
`)
})
it('materializes the automatic Workspace and Session on the first successful send', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await setComposerText(composer, 'build a lighthouse')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const session = within(tree).getByRole('treeitem', { selected: true })
if (group === null) throw new Error('materialized Workspace projection missing')
expect({
workspace: visibleText(group),
session: within(session).getByText('workspace', { exact: true }).textContent,
sessionSelected: session.getAttribute('aria-selected'),
promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
}).toMatchInlineSnapshot(`
{
"promptVisible": "build a lighthouse",
"session": "workspace",
"sessionSelected": "true",
"workspace": "workspace1 session",
}
`)
})
it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
boot('?fixture=empty&fixturePrompt=reject')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await setComposerText(composer, 'do not lose this')
await findLockedComposer()
await createWorkspaceViaPicker('nova')
const composer = await findHeroComposer()
setComposerText(composer, 'do not lose this')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
const retained = screen.getByDisplayValue('do not lose this')
// Failure restore rides the machine (no pendingPrompt transaction): the
// draft returns to the same resident textarea one render later. The
// attempt flips the composer out of the hero (engaging = retry chrome),
// but acceptance never happened: the session row stays New Session.
const retained = await screen.findByDisplayValue('do not lose this')
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const session = within(tree).getByRole('treeitem', { selected: true })
if (group === null) throw new Error('rejected-send Workspace projection missing')
expect({
workspace: visibleText(group),
session: within(session).getByText('workspace', { exact: true }).textContent,
error: visibleText(alert),
prompt: (retained as HTMLTextAreaElement).value,
blankRow: within(tree).getByText('New Session').textContent,
workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
"error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
"blankRow": "New Session",
"error": "fixture: prompt rejected before acceptance (agent-busy)",
"prompt": "do not lose this",
"session": "workspace",
"workspace": "workspace1 session",
"workspace": "nova1 session",
}
`)
})
it('switching Workspace before the first message carries the draft to the new blank session', async () => {
boot('?fixture')
const composer = await findHeroComposer()
setComposerText(composer, 'carry me')
// Switch = session switch: the new workspace's blank session takes over,
// the typed draft moves machine-to-machine, the old blank stays hidden.
await createWorkspaceViaPicker('nova')
await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
const carried = await screen.findByDisplayValue('carry me')
const tree = screen.getByRole('tree', { name: 'Sessions' })
const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
expect({
chip: visibleText(workspaceChip()),
prompt: (carried as HTMLTextAreaElement).value,
fixtureWorkspace: visibleText(fixtureGroup),
novaWorkspace: visibleText(novaGroup),
}).toMatchInlineSnapshot(`
{
"chip": "nova",
"fixtureWorkspace": "fixture3 sessions",
"novaWorkspace": "nova1 session",
"prompt": "carry me",
}
`)
})

View File

@@ -0,0 +1,160 @@
// Web e2e scenarios: workspace management — the create-by-name dialog, the
// rename round trip over the real wire (workspace.rename RPC + durable
// registry), duplicate-name pre-check, the flat "In one list" view with its
// persisted group-by preference, and the session hover card. Zero model
// calls: workspace.create/rename are host RPCs with no model involvement,
// and the one session row the flat/hover scenarios need comes from a seeded
// fixture (the seeded-history seed reused verbatim — no new recording).
import { mkdir, readFile, writeFile } 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, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', import.meta.url))
// The seed is another scenario's committed fixture, reused read-only: this
// spec needs any one cold session row, not new recorded content.
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'workspace-management-web-e2e'
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
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('creates two workspaces by name through the region-header dialog', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
const createByName = async (name: string): Promise<void> => {
await page.getByRole('button', { name: 'Create workspace' }).click()
// The pick menu's Create workspace submenu opens on hover/focus.
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('New workspace name').fill(name)
await dialog.getByRole('button', { name: 'Create workspace' }).click()
await expect.poll(() => page.getByRole('dialog', { name: 'Create a new workspace' }).count(), { timeout: 10_000 }).toBe(0)
// The real workspace materializes in the tree as a group row.
await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
}
await createByName('alpha-ws')
await createByName('beta-ws')
// Durable on the host: both registered, newest first (create prepends).
const titles = scaffold.ctx.workspace.list().map(workspace => workspace.title)
expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws'])
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('renames a workspace over the wire with a duplicate-name pre-check', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename'))
// The actions button is display:none until its row hovers — hover the
// group row first, then the revealed button becomes actionable.
await page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first().hover()
await page.getByRole('button', { name: 'Workspace actions for alpha-ws' }).click()
await page.getByRole('menuitem', { name: 'Rename' }).click()
const dialog = page.getByRole('dialog', { name: 'Rename workspace' })
await dialog.waitFor({ timeout: 10_000 })
const input = dialog.getByLabel('Workspace name')
// Client pre-check: a name colliding with another live workspace raises
// the inline alert and blocks the primary button before any wire call.
await input.fill('beta-ws')
await expect.poll(() => dialog.getByRole('alert').count(), { timeout: 5_000 }).toBe(1)
expect(await dialog.getByRole('button', { name: 'Rename' }).isDisabled()).toBe(true)
// A fresh name goes through workspace.rename to the durable registry.
await input.fill('gamma-ws')
await expect.poll(() => dialog.getByRole('alert').count(), { timeout: 5_000 }).toBe(0)
await dialog.getByRole('button', { name: 'Rename' }).click()
await expect.poll(() => page.getByRole('dialog', { name: 'Rename workspace' }).count(), { timeout: 10_000 }).toBe(0)
await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(await page.getByText('alpha-ws', { exact: true }).count()).toBe(0)
// Host durability, then reload: the projection is rebuilt from the wire.
expect(scaffold.ctx.workspace.list().map(workspace => workspace.title)).toContain('gamma-ws')
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('switches to the flat "In one list" view and persists the preference', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat'))
// Grouped default: workspace group rows render (the seeded session sits
// under Ungrouped; the created workspaces are empty groups).
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await page.getByRole('button', { name: 'Group by' }).click()
await page.getByRole('menuitem', { name: 'In one list' }).click()
// Flat mode: the section label flips and the seeded session is a
// top-level row with no group headers above it.
await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat')
// Persisted across reload; then restore grouped for inter-spec hygiene.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
await page.getByRole('button', { name: 'Group by' }).click()
await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Expand Ungrouped to reveal the seeded session row, then dwell on it
// (the card opens after a 500ms hover delay, portaled to body).
await page.getByText('Ungrouped', { exact: true }).click()
// A cold summary carries no durable title, so the row falls back to a
// cwd-derived display title — anchored on the run-local workspace-root
// basename rather than a literal.
const wsBase = scaffold.workspaceCwd.split('/').pop()!
const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first()
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.hover()
// Card content: the full title plus the Idle status line (display-only
// card; no aria role — text anchors are the stable selector).
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// Leaving the anchor closes it with no delay.
await page.getByRole('button', { name: '设置' }).hover()
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.warnings).toEqual([])
// This spec mints no fixture directory contents of its own; the seed it
// reuses is owned (and inventory-guarded) by seeded-history.
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep'])
})
})

View File

@@ -17,6 +17,23 @@
"src",
"tests"
],
// The web e2e lane (scaffold + replay specs) boots the host spine and reads
// its Context merges — host-plane programs, checked in tsconfig.host.json;
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/scaffold.ts",
"tests/live-interactions.e2e.ts",
"tests/question-composer.e2e.ts",
"tests/steering.e2e.ts",
"tests/navigation-panes.e2e.ts",
"tests/lifecycle-chrome.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/code-mode-round.e2e.ts"
],
"references": [
{
"path": "../../packages/client/web"