Merge remote-tracking branch 'origin/master' into feat/loader-entry-disabled-interpolation

This commit is contained in:
Huanqi Cao
2026-08-11 13:40:24 +08:00
535 changed files with 12687 additions and 1999 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

@@ -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

@@ -159,7 +159,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

@@ -176,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
// Custom group gone with its only member; the shipped set stands.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
// The custom group outlives its only member: the heading stays with the
// creator entry so the place to author a preset never disappears.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)

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,101 @@
// Keyless assembled-browser coverage for the /feedback command over the
// shipped Web bundles and the real host wire. The command plane settles
// without a model turn: the host appends the log-only command/run +
// feedback/record + command/done lifecycle, and the transcript renders the
// acknowledgement — the recorded session id plus the session-sharing
// disclosure — as a persistent command row. The scaffold mounts the shipped
// telemetry row in FULL mode against a local dead endpoint (no record leaves
// the process), so the golden pins the shipped default sentence
// `Session sharing is enabled.`; the per-status sentences are pinned by the
// package and OTel unit tests.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
const MODE = webSnapshotMode()
// Discard port: loopback listener never binds, so FULL telemetry discloses
// the shipped default policy without any record reaching a collector.
const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs'
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
describe('web e2e: /feedback command acknowledgement', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
telemetryUrl: TELEMETRY_URL,
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
})
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 })
// Fresh world: connecting a workspace births the blank session whose
// live composer accepts the slash line.
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 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-feedback-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 })
// Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot
// miss the turn/end that settles the recorded turn.
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 60_000)
it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command'))
// The drive test settled the recorded turn: the transcript is active (a
// command row does not render while a fresh session is still blank) and
// the replayed reply is on screen.
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
const input = page.locator('textarea').first()
await input.fill('/feedback the diff view is unreadable')
await input.press('Enter')
// The command plane settles without a model turn: the ack row names the
// recorded session and the mounted FULL backend's disclosure.
await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md'])
})
})

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

@@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', { name: /^Select model, current/ })
.waitFor({ timeout: 10_000 })
await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
await page.getByRole('button', { name: 'Copy' }).first().focus()

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

@@ -245,6 +245,13 @@ export interface LaunchOptions {
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
/**
* Mount the shipped telemetry row in FULL mode against this exporter URL
* instead of disabling it. Used to pin a real backend disclosure in
* assembled coverage; point the URL at a local dead endpoint so no record
* leaves the process.
*/
telemetryUrl?: string
/**
* Browse through a trusted non-loopback hostname that the browser resolves
* to loopback (for example `*.localhost`). The test server stays bound to
@@ -334,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} catch (error) {
const failures: unknown[] = [error]
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
restoreSkillRootEnvironment()
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
@@ -395,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
{ id: 'session-title-llm', disabled: true },
// Fixture sessions must never leave the process: the shipped row defaults
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
// names in the ambient environment). A scenario that pins a real backend
// disclosure passes a local dead endpoint instead of disabling the row.
options.telemetryUrl === undefined
? { id: 'telemetry-otel', disabled: true }
: { id: 'telemetry-otel', config: { exporter: { url: options.telemetryUrl }, shutdownTimeoutMillis: 1_000 } },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0 },

View File

@@ -468,9 +468,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (done?.type !== 'command/done') throw new Error('feedback command did not settle')
const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? []
expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`)
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i)
expect(extraLine).toBeUndefined()
const userId = userLine?.slice('User: '.length)
const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1]
if (userId === undefined) throw new Error('feedback command omitted the user id')
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))

View File

@@ -36,6 +36,7 @@ const EXPECTED_TOOLS = [
'list_agents',
'ralph',
'read',
'read_image',
'send_message',
'skill',
'subagent',

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

@@ -58,6 +58,7 @@
- 'button "复制: 创造模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- 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,39 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- img
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
- text: Think The user wants me to reply with a single word. Let me comply.
- paragraph: LIGHTHOUSE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."':
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."
- 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 "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok

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-official","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-official","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,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

@@ -38,10 +38,10 @@
- text: Context injection AGENTS.md
- img
- text: permission preset read-only
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]':
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]':
- img
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}"
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}"
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect([
Math.round(clickAreaBox!.x - treeBox!.x),
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
]).toEqual([5, 5])
// Menu padding alone insets the rows now that the border is gone.
]).toEqual([4, 4])
await compareOrRefreshGolden(
BRANCHLESS_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),

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",
@@ -64,6 +65,7 @@
"tests/agent-preset-selection.e2e.ts",
"tests/agent-preset-authoring.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/feedback-command.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
"tests/produced-file-mentions.e2e.ts",
@@ -72,6 +74,7 @@
"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 {