Merge origin/master into worktree/web-plugin-config

Three seams: the tsconfig path map gained a mapping on each side and keeps
both; the event-producer matrix is generated, so it was regenerated rather
than hand-merged row by row.
This commit is contained in:
Yichen Jiang
2026-08-11 18:27:53 +08:00
1460 changed files with 20030 additions and 19474 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 4fae5338a89ce12c2620e123530acf883ae9efff
README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab
README.md: 98a856261bc632c97f350db8fc7bb0b10c22235d
README.zh.md: 283e54138e24202ed6b88d1d309538c58cd66b3e

View File

@@ -31,8 +31,8 @@ dsh --help # the launcher's own help
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher.
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution.
## Development
Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract.
Production runs require built package and frontend artifacts. From the repository root, `pnpm dsh <args...>` builds those artifacts, runs the TypeScript entry, and forwards every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.

View File

@@ -31,8 +31,8 @@ dsh --help # the launcher's own help
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest元数据清单`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base``@deepseek-ai/dsh-web-app``@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析pnpm 把树外插件安装在后者。使用 `--dump-default-config``--dump-config` 可在不启动的情况下检查组合后的配置树。
[CLI命令行界面行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器
[CLI命令行界面行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码执行
## 开发
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh`运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约
生产运行需要已构建的包与前端产物。从仓库根目录运行 `pnpm dsh <args...>` 会先构建这些产物,再运行 TypeScript 入口并转发所有参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责

View File

@@ -129,17 +129,20 @@
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the rows here resolve that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'

View File

@@ -110,17 +110,20 @@
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the rows here resolve that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'

View File

@@ -49,16 +49,19 @@
# Model capacity comes from routed model metadata; this block states the
# compaction policy explicitly.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the row here resolves that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
config:

View File

@@ -122,17 +122,20 @@
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the rows here resolve that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: fd0647347312051a1814a5e3464b34032ae70dfc
README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617
README.md: cef687dc392f97886ea18b162e8f668a44ce2284
README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82

View File

@@ -79,12 +79,6 @@ Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` sel
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
## Source launcher
## Source execution
Link the source-running launcher onto PATH:
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`.
From the repository root, use `pnpm dsh <args...>`. The `package.json` script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Build output appears before CLI output. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository.

View File

@@ -79,12 +79,6 @@ dsh web --help
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent智能体沙箱之外的受信任可执行代码。
## 源码启动器
## 源码执行
把源码运行启动器链接到 PATH
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
它通过 real path 解析 checkout并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts``TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`
请从仓库根目录使用 `pnpm dsh <args...>``package.json` 中的脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建输出会显示在 CLI 输出之前。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY``HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。

View File

@@ -85,14 +85,14 @@ describe('dsh badge assembled snapshot', () => {
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
## Markdown
Use this linked badge in Markdown:
\`\`\`markdown
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk)
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
\`\`\`
If attribution should not be linked, use:
@@ -124,14 +124,14 @@ describe('dsh badge assembled snapshot', () => {
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
## Markdown
Use this linked badge in Markdown:
\`\`\`markdown
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk)
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
\`\`\`
If attribution should not be linked, use:

View File

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

View File

@@ -1,11 +1,12 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
/**
* Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts`
* with the exact production launch vector (`node --import tsx/esm`, the same
* executable and arguments as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the
* Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts`
* with the exact production runtime vector (`node --import tsx/esm`, the
* vector the root `dsh` script invokes after building) and assert the
* required-config diagnostic. The Node compatibility matrix runs this
* WHOLE file, so a Node release changing module hooks or TypeScript handling
* breaks this gate instead of every developer's `pnpm dsh`; the built-bin
@@ -16,6 +17,13 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshSourceBin = 'apps/cli/src/bin.ts'
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
it('builds before launching the source CLI', async () => {
const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as {
readonly scripts?: Record<string, string>
}
expect(rootPackage.scripts?.dsh).toBe('pnpm run build && node --import tsx/esm apps/cli/src/bin.ts')
})
it('boots the source entry and requires a profile', async () => {
const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
cwd: repoRoot,

View File

@@ -17,6 +17,9 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type {} from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-tools'
// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-token-meter'
const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
@@ -138,6 +141,33 @@ describe('the shipped Web composition', () => {
expect(toolNames(ctx)).toEqual([])
})
it('keeps the token meter and its context-meter projections on the host plane', async () => {
// Read before any preset in this file mounts, which is what makes this an
// ownership assertion rather than a mount-order coincidence: a preset-side
// meter sits behind an `isolate` realm and is invisible to `ctx.get`.
//
// The projection registry is process-wide rather than scope-layered, so a
// preset-side meter would also make the browser's context meter appear for
// a `minimal` session the moment some OTHER session mounted a preset that
// carries one, and vanish entirely in a process that only ever ran
// `minimal`. Host ownership is what makes the meter a per-session fact.
expect(ctx.get('tokenMeter')).toBeDefined()
const projections = ctx.get('sessionProjections')
if (projections === undefined) throw new Error('the Web composition must compose a projection registry')
const handle = await ctx.agents.create({
sessionId: SessionId('preset-minimal-meter'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
// A subset assertion: `tasks`, `goal`, and the rest register into the
// same process-wide table, and this is about the meter's three units.
expect(Object.keys(projections.snapshot(handle.agent.session).values))
.toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage']))
} finally {
await handle.dispose()
}
})
it('supplies both shipped presets, and only those, from the system root', async () => {
const listed = await ctx.agentPresets.list()
@@ -159,7 +189,7 @@ describe('the shipped Web composition', () => {
// depend on ripgrep being present on the machine.
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
'subagent', 'subagent_fork', 'task_kill',
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
'workflow', 'write',

View File

@@ -44,6 +44,7 @@
"playwright": "^1.49.0",
"typescript": "^6.0.3",
"vite": "^6.0.0",
"vitest": "^4.1.8"
"vitest": "^4.1.8",
"fflate": "^0.8.2"
}
}

View File

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

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -196,18 +196,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '通用设置' }).click()
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
// Both damage shapes surface as marked, unselectable, uncopyable cards
// that still carry their metadata and the discovery-reported reason.
expect(snapshot).toContain('已损坏: broken-yaml')
expect(snapshot).toContain('已损坏: 幽灵预设')
expect(snapshot).toContain('加载失败: broken-yaml')
expect(snapshot).toContain('加载失败: 幽灵预设')
expect(snapshot).toContain('not valid YAML')
expect(snapshot).toContain('agent.cordis.yml is missing')
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
// A broken card offers no "set default" affordance at all — the aria name
// IS the broken marking, so the picking name must not exist.

View File

@@ -0,0 +1,133 @@
// Web e2e scenario: the session-header background-task list over the real
// host. No model call is involved — a genuine `run_in_background` bash call
// registers with `ctx.tasks`, and the assertion chain is the whole delivery
// path: registry change feed → api-proxy `session/tasks` frame → the client's
// `tasksBySession` mirror → the header action.
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 { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-task-list', import.meta.url))
const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'background-task-list-web-e2e'
// Long enough that the running assertions never race the process exiting on
// their own; the test kills it explicitly to reach the settled state.
const COMMAND = 'sleep 45'
/**
* Wait for the Host to publish the live Agent that opening a session resumes.
* @param scaffold - the booted web scaffold.
* @param sessionId - the opened session's identity.
* @returns the registered Agent instance.
*/
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
const deadline = Date.now() + 30_000
for (;;) {
const found = scaffold.ctx.agents.get(sessionId)
if (found !== undefined) return found
if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
await new Promise(resolve => setTimeout(resolve, 100))
}
}
describe.skipIf(MODE === 'record')('web e2e: background task list', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let agent: Agent
let taskId: TaskId
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
// Opening the session drives the Host's ordinary Agent resolution; the
// task owner must be that exact live instance, never a second one.
// `expect.poll` is test-scoped, so this hook polls by hand.
agent = await liveAgent(scaffold, SessionId(SEED_ID))
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows a running background task in the session header without a refresh', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running'))
// Point assertion, not a poll: `expect.poll` retries until a predicate
// holds, so polling for zero passes at t=0 and proves nothing. The
// "renders nothing without a task" branch is owned by the component suite.
const trigger = page.getByRole('button', { name: '1 background task running' })
expect(await trigger.count()).toBe(0)
const started = await scaffold.ctx.tools.execute({
signal: new AbortController().signal,
callId: CallId('background-task-list-e2e'),
name: 'bash',
arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true },
agent,
})
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
const matched = /\bbash-\d+\b/.exec(reported)
if (matched === null) throw new Error(`background bash reported no task id: ${reported}`)
taskId = TaskId(matched[0])
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
const row = page.getByRole('list', { name: 'Background tasks' }).getByRole('listitem').first()
await row.waitFor({ timeout: 10_000 })
await expect.poll(() => row.textContent()).toContain(COMMAND)
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('flips the open list to the cancelled outcome when the registry settles it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-settled'))
expect(scaffold.ctx.tasks.kill(taskId, agent, 'web e2e cancellation')).toBe('requested')
// The trigger drops its live count once the task leaves running/stopping,
// which is also the proof that settlement reached the browser unprompted.
const idle = page.getByRole('button', { name: '1 background task' })
await idle.waitFor({ timeout: 20_000 })
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md'])
})
})

View File

@@ -0,0 +1,123 @@
// Web e2e: /goal opts its command input into the human transcript while the
// command remains log-only. The shipped composition runs with no model adapter,
// so an accidental turn fails loud in addition to the event-level assertions.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria,
compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-command-presentation', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL(
'./snapshots/goal-command-presentation/ui.expected.md', import.meta.url,
))
const MODE = webSnapshotMode()
describe('web e2e: /goal human transcript presentation', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const events: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold()
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows the bare input and result from a fresh session without a model turn', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation'))
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
timeout: 15_000,
}).toBe(1)
const input = page.locator('textarea').first()
await input.fill('/goal')
await input.press('Enter')
await expect.poll(() => input.inputValue()).toBe('/goal ')
await input.press('Enter')
const commandInput = page.locator('[data-command-input]')
await commandInput.waitFor({ timeout: 10_000 })
await expect.poll(() => commandInput.textContent()).toBe('/goal')
expect(await commandInput.getAttribute('role')).toBe('group')
expect(await commandInput.getAttribute('aria-label')).toBe('Command input')
expect(await commandInput.getByRole('button').count()).toBe(0)
const typography = await commandInput.evaluate((element) => {
const bubble = element.firstElementChild?.firstElementChild
if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
const rootStyle = getComputedStyle(element)
const bubbleStyle = getComputedStyle(bubble)
return {
fontFamily: bubbleStyle.fontFamily,
parentFontFamily: rootStyle.fontFamily,
fontSize: bubbleStyle.fontSize,
lineHeight: bubbleStyle.lineHeight,
}
})
expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
expect(await page.getByText('Into the Unknown', { exact: false }).count()).toBe(0)
const run = events.find(event => event.type === 'command/run')
expect(run).toMatchObject({
type: 'command/run',
data: { name: 'goal', args: ' ', source: { kind: 'user' } },
})
expect(events.some(event => event.type === 'command/done')).toBe(true)
expect(events.some(event => event.type === 'user/message')).toBe(false)
expect(events.some(event => event.type === 'turn/start')).toBe(false)
expect(events.some(event => event.type === 'step/start')).toBe(false)
expect(events.some(event => event.type === 'request/header')).toBe(false)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}, 60_000)
it('reloads the same bubble and result from the persisted command lifecycle', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation-reload'))
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.locator('[data-command-input]').textContent(), { timeout: 15_000 }).toBe('/goal')
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
const sessions = scaffold.ctx.sessions.list()
expect(sessions).toHaveLength(1)
const persisted = sessions[0]?.events ?? []
expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done')
.map(event => event.type)).toEqual(['command/run', 'command/done'])
expect(persisted.some(event => event.type === 'user/message')).toBe(false)
expect(persisted.some(event => event.type === 'turn/start')).toBe(false)
expect(persisted.some(event => event.type === 'step/start')).toBe(false)
expect(persisted.some(event => event.type === 'request/header')).toBe(false)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 90_000)
})

View File

@@ -119,6 +119,10 @@ describe('web e2e: settled Markdown math rendering', () => {
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
await expect.poll(
() => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(),
{ timeout: 10_000 },
).toBe(1)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')

View File

@@ -0,0 +1,115 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
assertFixtureInventory,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
type WebScaffold,
} from './scaffold.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url))
const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
const SESSION_ID = 'message-feedback-protocol'
const MESSAGE_ID = '11111111-1111-4111-8111-111111111111'
interface ProtocolExchange {
readonly endpoint: string
readonly request: unknown
readonly status: number
readonly response: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
function createdVersion(response: unknown): string {
if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
|| !isRecord(response.result.value) || response.result.value.ok !== true
|| !isRecord(response.result.value.value)
|| typeof response.result.value.value.version !== 'string') {
throw new Error('messageFeedback.put did not return a successful versioned item')
}
return response.result.value.value.version
}
/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
return JSON.stringify(exchanges, (key, value: unknown) => {
if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
return value
}, 2)
}
describe('message feedback Host Remote protocol', () => {
let scaffold: WebScaffold
beforeAll(async () => {
scaffold = await launchWebScaffold()
await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
})
afterAll(async () => {
await scaffold?.close()
})
it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
const exchanges: ProtocolExchange[] = []
const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
const payload = { args: { request } }
const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId,
method: endpoint,
payload,
}),
})
const body: unknown = await response.json()
exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
return body
}
await invoke('feedback-invalid', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'invalid-rating',
ifVersion: null,
})
await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
const created = await invoke('feedback-put', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'positive',
note: 'Useful answer',
ifVersion: null,
})
const version = createdVersion(created)
expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
await invoke('feedback-conflict', 'messageFeedback/put', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
rating: 'negative',
ifVersion: null,
})
await invoke('feedback-delete', 'messageFeedback/delete', {
sessionId: SESSION_ID,
messageId: MESSAGE_ID,
ifVersion: version,
})
await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl'])
})
})

View File

@@ -4,8 +4,9 @@
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
// while the settings document records only that reference. Each saved row
// appears after route topology invalidation without presenting liveness as
// provider status. The customized-settings fold writes the curated
// reasoning field as a merge patch. Zero model calls: configuration is pure
// provider status. The customized-settings fold writes its curated fields —
// the endpoint, and a declared route's own name and protocol — as merge
// patches against the stored profile. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud because the adapter registry is empty. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
@@ -28,6 +29,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md')
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
@@ -209,6 +211,40 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('reopens the name and protocol a declared route was created with', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declared-identity'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 Acme Gateway (acme-gateway)' }).click()
await dialog.getByText('自定义设置').click()
// The create card asked this route for a name and a protocol because
// nothing can default them; the editor reaches the same two fields rather
// than sending the user to settings.yaml for what only this route names.
const protocol = dialog.getByLabel('API 协议')
await protocol.waitFor({ timeout: 10_000 })
expect(await protocol.inputValue()).toBe('openai-completions')
const name = dialog.getByLabel('显示名称', { exact: true })
expect(await name.inputValue()).toBe('Acme Gateway')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
await protocol.selectOption('anthropic-messages')
await name.fill('Acme 网关')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
// The adapter re-resolved the route under the new protocol and re-registered
// it under the new name: an unserviceable profile would have been refused
// at the write instead, and a rename that did not re-register would leave
// the old label on the row.
await dialog.getByText('Acme 网关', { exact: true }).first().waitFor({ timeout: 10_000 })
// The status line names the route as the refreshed directory reports it;
// the target captured when the card opened still carries the old name.
await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('api: anthropic-messages')
expect(document).toContain('displayName: Acme 网关')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('confirms an identified provider deletion before removing its profile and key', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
@@ -243,8 +279,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
'empty.expected.md', 'native-delete.expected.md',
'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
])
})
})

View File

@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page, Response } from 'playwright'
import { chromium } from 'playwright'
import { strFromU8, unzipSync } from 'fflate'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -274,6 +275,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await details.getByRole('button', { name: 'Close details' }).click()
}, 60_000)
it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
await ensureSeedOpen(page)
await page.getByRole('tab', { name: 'Trajectory' }).click()
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
await page.getByRole('button', { name: 'Export session log' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/)
// The real host streamed the ZIP; its root entry is the persisted log
// text verbatim (the assembled seam: real route, real persistence read).
const files = unzipSync(await readFile(await download.path()))
expect(Object.keys(files)).toEqual(['session.jsonl'])
const content = strFromU8(files['session.jsonl'] as Uint8Array)
expect(content.split('\n')[0]).toContain(SEED_ID)
expect(content).toContain('FIRST_DONE')
}, 60_000)
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
await ensureSeedOpen(page)

View File

@@ -25,3 +25,11 @@ it('ships install metadata with the built web application', async () => {
}],
})
})
it('ships a favicon that switches to a light mark under dark color scheme', async () => {
const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8')
// The light fill must live inside the dark-scheme media query, so the icon
// stays black in light mode and only turns white under a dark scheme.
expect(favicon).toMatch(/@media \(prefers-color-scheme: dark\)\s*{\s*path\s*{[^}]*fill:\s*#fff/i)
expect(favicon).toContain('fill="#000"')
})

View File

@@ -18,7 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import { join } from 'node:path'
@@ -195,21 +194,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
// The meter belongs to an agent's preset, not to the process — token
// accounting is per session. It is used here as a pure pricing function
// over fixture content, so a throwaway composition is enough to reach one.
const priced = await scaffold.ctx.agents.create({
sessionId: SessionId('seeded-history-pricing'),
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
let realizedWithCompaction: string
try {
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
} finally {
await priced.dispose()
}
// The meter is host-plane — it takes no configuration and keys every
// fold by Session — so pricing fixture content needs no agent at all.
const meter = scaffold.ctx.get('tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the host token meter')
const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
}
browser = await chromium.launch()

View File

@@ -152,6 +152,67 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('uses the persisted dark preference while plugins are still loading', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme'))
await page.emulateMedia({ colorScheme: 'light' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const initialDialog = page.getByRole('dialog', { name: '设置' })
const darkCube = initialDialog.getByRole('button', { name: '深色' })
await darkCube.click()
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-theme:\n\s+preference: dark/)
await page.keyboard.press('Escape')
// Hold real plugin bundles so the shell-owned loading page remains observable.
const pluginPattern = '**/plugins/**'
let releaseBundles = (): void => {}
const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
await page.route(pluginPattern, async (route) => {
await bundlesReleased
await route.continue()
})
const warningStart = tripwire.warnings.length
let reload: ReturnType<Page['reload']> | undefined
try {
reload = page.reload({ waitUntil: 'domcontentloaded' })
const loading = page.getByText('Loading plugins…', { exact: true })
await loading.waitFor({ timeout: 10_000 })
const state = await loading.evaluate((element) => {
const boot = element.parentElement?.parentElement
if (boot === undefined || boot === null) throw new Error('loading hint is detached from the boot page')
return {
attr: document.body.hasAttribute('data-ds-dark-theme'),
background: getComputedStyle(boot).backgroundColor,
colorScheme: document.documentElement.style.colorScheme,
}
})
expect(state).toEqual({
attr: true,
background: 'rgb(21, 21, 23)',
colorScheme: 'dark',
})
} finally {
releaseBundles()
await reload
await page.unroute(pluginPattern)
}
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '设置', exact: true }).click()
const restoredDialog = page.getByRole('dialog', { name: '设置' })
const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), {
timeout: 5_000,
}).toBe(false)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
interface ThemeState {

View File

@@ -36,6 +36,7 @@ const EXPECTED_TOOLS = [
'list_agents',
'ralph',
'read',
'read_image',
'send_message',
'skill',
'subagent',
@@ -144,7 +145,7 @@ it('lets a preset producer reach the background-task registry', async () => {
content: [{ type: 'text', text: 'started background task bash-1' }],
})
// The control surface reads what the producer started: same registry, one
// The controller reads what the producer started: same registry, one
// owner. A per-preset registry would list nothing here even on success.
const listed = await ctx.tools.execute({
signal,

View File

@@ -478,7 +478,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
requireDist()
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
const port = await probeFreePort()
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
// tsx boot mirrors the runtime half of the root dsh script. Isolate
// the host-level Harness and shared-agent homes inside the temp world; tsx
// also needs the repo's loader and tsconfig paths pointed at explicitly.
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href

View File

@@ -64,8 +64,8 @@
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "已损坏: broken-yaml" [disabled]':
- text: broken-yaml 已损坏 自定义 暂无描述。
- 'button "加载失败: broken-yaml" [disabled]':
- text: broken-yaml 加载失败 自定义 暂无描述。
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
- code: broken-yaml
- 'button "查看路径: broken-yaml"':
@@ -73,13 +73,13 @@
- text: 查看路径
- 'button "复制: broken-yaml" [disabled]':
- img
- text: 预设已损坏,无法复制
- text: 预设加载失败,不能复制
- 'button "删除: broken-yaml"':
- img
- text: 删除
- listitem:
- 'button "已损坏: 幽灵预设" [disabled]':
- text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
- 'button "加载失败: 幽灵预设" [disabled]':
- text: 幽灵预设 加载失败 自定义 composition 已被手动删除。
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
- code: ghost
- 'button "查看路径: 幽灵预设"':
@@ -87,7 +87,7 @@
- text: 查看路径
- 'button "复制: 幽灵预设" [disabled]':
- img
- text: 预设已损坏,无法复制
- text: 预设加载失败,不能复制
- 'button "删除: 幽灵预设"':
- img
- text: 删除

View File

@@ -0,0 +1,2 @@
- list "Background tasks":
- listitem: bash sleep 45 running {{duration}}

View File

@@ -0,0 +1,2 @@
- list "Background tasks":
- listitem: "bash sleep 45 signal: SIGTERM {{duration}}"

View File

@@ -0,0 +1,21 @@
- banner:
- navigation "Session hierarchy":
- button "workspace" [disabled]
- img
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- group "Command input": /goal
- 'button "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"':
- img
- img
- text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- group "Command input": /goal 做两个turn每个turn输出随机一个包的文件结构。注意你做完一个turn之后直接输出内容停止我们的系统会帮你再开一个turn你看着做一个类似的
- 'button "goal Goal created Status: active Objective: 做两个turn每个turn输出随机一个包的文件结构。注意你做完一个turn之后直接输出内容停止我们的系统会帮你再开一个turn你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
- img
- img

View File

@@ -0,0 +1,203 @@
[
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "invalid-rating",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-invalid",
"result": {
"ok": false,
"error": {
"code": "internal",
"message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation",
"details": {}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-empty",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": []
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-put",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-created",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": [
{
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
]
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/put",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "negative",
"ifVersion": null
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-conflict",
"result": {
"ok": true,
"value": {
"ok": false,
"error": {
"code": "version-conflict",
"current": {
"messageId": "11111111-1111-4111-8111-111111111111",
"rating": "positive",
"note": "Useful answer",
"version": "{{version}}",
"createdAt": "{{timestamp}}",
"updatedAt": "{{timestamp}}"
}
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/delete",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol",
"messageId": "11111111-1111-4111-8111-111111111111",
"ifVersion": "{{version}}"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-delete",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"absent": true
}
}
}
}
},
{
"endpoint": "/api/messageFeedback/list",
"request": {
"args": {
"request": {
"sessionId": "message-feedback-protocol"
}
}
},
"status": 200,
"response": {
"type": "server-response",
"rpcId": "feedback-list-deleted",
"result": {
"ok": true,
"value": {
"ok": true,
"value": {
"items": []
}
}
}
}
}
]

View File

@@ -0,0 +1,7 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}}
{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}}
{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"}
{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,65 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
- img "API 密钥已配置"
- button "编辑 minimax-cn": 编辑
- button "删除 minimax-cn": 删除
- listitem:
- text: Acme Gateway 自定义
- button "编辑 Acme Gateway (acme-gateway)": 编辑
- button "删除 Acme Gateway (acme-gateway)": 删除
- text: Acme Gateway acme-gateway API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group:
- text: 自定义设置 显示名称
- textbox "显示名称":
- /placeholder: acme-gateway
- text: Acme Gateway
- text: API 地址
- textbox "API 地址":
- /placeholder: https://gateway.acme.example/v1
- text: https://gateway.acme.example/v1
- text: API 协议
- combobox "API 协议":
- option "openai-completions" [selected]
- option "openai-responses"
- option "anthropic-messages"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: acme-large
- textbox "显示名称 1":
- /placeholder: 显示名称
- button "容量 1"
- button "删除模型 1"
- button "添加模型"
- button "取消"
- button "保存"
- button "添加提供方":
- img
- text: 添加提供方
- button "添加自定义提供方":
- img
- text: 添加自定义提供方

View File

@@ -2,6 +2,7 @@
- button "Use actual duration": Duration
- button "Collapse turns": Turns
- button "Collapse calls": Calls
- button "Export session log": Export
- img
- searchbox "Search trajectory"
- region "Trajectory timeline":

View File

@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- group "Command input": /goal Keep the composer context panels aligned
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
- img
- img

View File

@@ -57,9 +57,9 @@ export function probeFreePort(): Promise<number> {
/**
* Drive the hero's workspace picker through the composed directory 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
* lands in the Workspace-trigger view state (startup auto-selection has nothing to
* select); every scenario that types into the composer must connect one
* first. With nothing to list, the chip gesture raises the dialog directly —
* first. With nothing to list, activating the textarea raises the dialog directly —
* adding a workspace is the picker's only entry. The directory is staged here
* and adopted through the path editor, which is idempotent across the repeated
* connects a scenario may make; creating a folder from inside the dialog (the
@@ -73,7 +73,7 @@ export function probeFreePort(): Promise<number> {
*/
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByRole('textbox', { name: 'Choose workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
@@ -97,7 +97,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
*/
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
await page.getByRole('textbox', { name: '选择工作区' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()

View File

@@ -99,6 +99,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
}
}
/**
* Reveal and click a row action, re-hovering if a projection update replaces
* the row before its hover-only button becomes visible.
*/
async function clickHoverAction(row: Locator, name: string): Promise<void> {
const button = row.getByRole('button', { name })
await expect.poll(async () => {
await row.hover()
return await button.isVisible()
}, { timeout: 10_000 }).toBe(true)
await button.click()
}
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
@@ -137,10 +150,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
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()
const alphaRow = page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first()
await clickHoverAction(alphaRow, 'Workspace actions for alpha-ws')
await page.getByRole('menuitem', { name: 'Rename' }).click()
const dialog = page.getByRole('dialog', { name: 'Rename workspace' })
await dialog.waitFor({ timeout: 10_000 })
@@ -214,17 +225,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
// The header row is wrapped by its HoverCard anchor span, so the section
// is the nearest groupSection ancestor, not the immediate parent.
const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
await expect.poll(
() => groupSection.locator('[role="treeitem"]').count(),
{ timeout: 10_000 },
).toBeGreaterThanOrEqual(2)
await expect.poll(async () => {
const count = await groupSection.locator('[role="treeitem"]').count()
if (count < 2 && await groupRow.getAttribute('aria-expanded') !== 'true') {
await groupRow.click()
await page.waitForTimeout(50)
}
return await groupSection.locator('[role="treeitem"]').count()
}, { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
const seededRow = groupSection.locator('[role="treeitem"]').nth(1)
await seededRow.click()
await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
await groupRow.hover()
await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click()
await clickHoverAction(groupRow, `Workspace actions for ${workspace.title}`)
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Delete workspace' })
await dialog.waitFor({ timeout: 10_000 })
@@ -338,8 +351,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
await oldRow.hover()
await page.getByRole('button', { name: `Workspace actions for ${title}` }).click()
await clickHoverAction(oldRow, `Workspace actions for ${title}`)
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
await page.getByRole('dialog', { name: 'Delete workspace' })
.getByRole('button', { name: 'Delete workspace' }).click()
@@ -509,9 +521,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
const sessionRow = await seededSessionRow()
// The trigger is display:none until its row hovers.
await sessionRow.hover()
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
await trigger.click()
const triggerName = await trigger.getAttribute('aria-label')
if (triggerName === null) throw new Error('seeded Session row has no actions label')
await clickHoverAction(sessionRow, triggerName)
const item = page.getByRole('menuitem', { name: 'Rename' })
await item.waitFor({ timeout: 5_000 })
// Into the list, then back up to the trigger across the 4px gap below it:
@@ -559,8 +572,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
// Row menu: hover reveals the actions button; Archive session commits
// without a confirmation dialog (non-destructive: log + accounting stay).
await sessionRow.hover()
await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
await clickHoverAction(sessionRow, `Session actions for ${rowTitle}`)
await page.getByRole('menuitem', { name: 'Archive session' }).click()
// The row disappears on the archive-set echo; with no other visible
// stray, the whole Ungrouped bucket withdraws.

View File

@@ -25,6 +25,7 @@
"tests/scaffold.ts",
"tests/scaffold-hermetic.e2e.ts",
"tests/minimal-preset.snapshot.ts",
"tests/message-feedback-protocol.snapshot.ts",
"tests/live-interactions.e2e.ts",
"tests/question-composer.e2e.ts",
"tests/approval-composer.e2e.ts",
@@ -70,10 +71,12 @@
"tests/produced-files.e2e.ts",
"tests/produced-file-mentions.e2e.ts",
"tests/goal-bar.e2e.ts",
"tests/goal-command-presentation.e2e.ts",
"tests/subagent-conversation.e2e.ts",
"tests/subagent-interrupt.e2e.ts",
"tests/subagent-interrupt-ui.e2e.ts",
"tests/sidebar-subagent-activity.e2e.ts",
"tests/background-task-list.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.e2e.ts",
"tests/turn-tail-actions.e2e.ts",

View File

@@ -5,8 +5,8 @@ import react from '@vitejs/plugin-react'
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
+ 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). '
+ 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.'
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
+ 'For client-plugin HMR, run `pnpm dsh web --dev` together with `pnpm run dev:web`.'
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
function rejectStandaloneServe(): Plugin {
@@ -130,7 +130,7 @@ export default defineConfig({
// Workspace packages resolve to SOURCE: package.json exports point at lib
// for Node/type consumers, but the browser bundle must compile src directly
// so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
// Only the shell's normal-package surface is aliased — plugin packages are
// Only the shell's normal package entry is aliased — plugin packages are
// NEVER bundled here (shell self-sufficiency — see
// packages/client/web/README.md); they arrive as runtime
// bundles through the client module system. Order matters — subpath