Merge branch 'master' into feat/agent-action

This commit is contained in:
07akioni
2026-07-29 14:21:44 +08:00
249 changed files with 8947 additions and 709 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: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9
README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b
README.md: 6b3a31a30a941e67341a518a7e32b1bf99eee8b0
README.zh.md: 133c721612012b8fd1327344ae4e2951d05047cb

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
The TUI surface:
@@ -16,6 +16,8 @@ The TUI surface:
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
## Install (developer machine)

View File

@@ -4,7 +4,7 @@
`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent智能体`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
TUI 界面:
@@ -16,6 +16,8 @@ TUI 界面:
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
## 安装(开发机)

View File

@@ -86,6 +86,19 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# Common pi-ai provider routes read credentials and endpoint overrides from the
# boot's layered environment.
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
- provider: openai
apiKey: !!js process.env.OPENAI_API_KEY
baseURL: !!js process.env.OPENAI_BASE_URL
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
baseURL: !!js process.env.ANTHROPIC_BASE_URL
# Transient-failure recovery around the loop's model calls (same policy as
# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff).
- id: llm-retry
@@ -130,8 +143,45 @@
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
# The sandboxed product path (the acp-agent composition): per-platform
# runner provider, the shared policy home, the confined bash executor, and
# the approval seam its escalation asks through. The web deployment default
# is danger-full-access + never (same behavior as the former bash-local
# rows); DSH_PERMISSION_MODE opts a process into a confined default, and
# per-session switches ride the /permission command's knob events.
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
# Presets over the two knobs (requires the confining executor + approval):
# the web permission chip's table, served through the permissions projection
# and switched through /permission.
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
@@ -143,9 +193,11 @@
name: '@deepseek-ai/dsh-tool-tasks'
# fs cwd stays the package default (process.cwd()) — the same value the
# gateway injects into session.cwd, so paths and sessions agree.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
# gateway injects into session.cwd, so paths and sessions agree. The
# sandboxed backend rides the SAME policy as bash: write/edit fence by the
# effective mode, so read/write/edit stay available under every mode.
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
@@ -262,6 +314,13 @@
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
# Directory-picking package, dual-face: the node half serves the gateway's
# host.* picker RPCs, the browser half fills ui-workspace's directory-flow
# slots — one row composes the whole interaction. Swap point: mount
# '-native' instead for the host-display OS chooser.
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-browse'
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
@@ -346,6 +405,10 @@
- id: ui-model
name: '@deepseek-ai/dsh-client-ui-model'
# The /permission popup picker (hostBacked over the host /permission command).
- id: ui-permission
name: '@deepseek-ai/dsh-client-ui-permission'
# Plan control: the composer plan seat over the plan projection + /plan channel.
- id: ui-plan
name: '@deepseek-ai/dsh-client-ui-plan'

View File

@@ -20,7 +20,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -32,6 +32,7 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
@@ -48,17 +49,23 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
@@ -90,6 +97,7 @@
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",

View File

@@ -9,6 +9,7 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { networkInterfaces } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
@@ -25,6 +26,41 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Non-internal IPv4 interface addresses of this machine — the IP-literal
* authorities an all-interfaces bind is reachable by on the LAN.
* @returns the addresses in interface order (possibly empty).
*/
function lanIPv4Addresses(): string[] {
return Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
}
/**
* One LAN-trust resolution for one invocation, sampled exactly once: the
* machine's LAN IP literals when the effective bind is all-interfaces, and
* the `trustedHosts` value built from them plus the explicit extras. The
* single sample is deliberate — display must advertise only addresses the
* fence was configured with, so both read this snapshot. Derived entries are
* port-less IP literals: DNS rebinding needs an attacker-controlled name, so
* an IP-literal Host is safe on any port, and the bound port may be
* OS-assigned, unknowable pre-boot.
* @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
* @param extra - `--trusted-host` values, in argv order.
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
*/
export function resolveLanTrust(
bindHost: string | undefined,
extra: readonly string[],
): { lanAddresses: string[]; trustedHosts: string[] } {
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -79,6 +115,8 @@ export interface AppCLIEntryOptions {
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
}
/**
@@ -91,6 +129,14 @@ export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
/**
* LAN IPv4 addresses sampled once at patch composition — the exact snapshot
* the /api trust fence was configured with. Display reads this instead of
* re-sampling, so the advertised LAN URL can never name an address the
* fence rejects. Empty unless the effective bind is all-interfaces.
*/
lanAddresses: readonly string[] = []
private patches: PatchOptions[] = []
constructor(private readonly options: AppCLIEntryOptions) {}
@@ -152,6 +198,13 @@ export class AppCLIEntry {
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 2b: authorities for the /api browser-trust fence (rationale on
// resolveLanTrust).
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
this.lanAddresses = lanAddresses
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())

View File

@@ -40,6 +40,8 @@ interface WebInvocation {
port?: number
dev: boolean
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
trustedHosts?: string[]
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
@@ -51,6 +53,7 @@ interface WebOptions {
port?: string
dev?: boolean
workspaceRoot?: string
trustedHost?: string[]
}
/**
@@ -66,6 +69,7 @@ function resolveWeb(options: WebOptions): WebInvocation {
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
}
}
@@ -117,6 +121,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.action((options: WebOptions) => {
// Commander parses the parent (default-surface) options on either side of
// the subcommand into `program.opts()`. `web` shares none of them, so a

View File

@@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot)
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts)
break
}
case 'headless': {

View File

@@ -6,17 +6,14 @@
* gates them at boot.
*/
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
// Display-only mirrors of the webserver schema's allowed hosts: the loopback
// address the local URL always prints, and the all-interfaces value that gates
// LAN-address discovery. Not a source of truth — the schema is.
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
@@ -25,12 +22,14 @@ const ALL_INTERFACES_HOST = '0.0.0.0'
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
*/
export async function runWeb(
host: string | undefined,
port: number | undefined,
dev: boolean,
workspaceRoot: string | undefined,
trustedHosts: string[] | undefined,
): Promise<void> {
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
@@ -38,6 +37,7 @@ export async function runWeb(
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
@@ -48,12 +48,11 @@ export async function runWeb(
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lanCandidate = host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })

View File

@@ -35,6 +35,9 @@ describe('parseDshArgs', () => {
// at boot); the adapter only coerces the port string to a number.
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
})
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {

View File

@@ -0,0 +1,33 @@
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
import { describe, expect, it, vi } from 'vitest'
import { resolveLanTrust } from '../src/app-cli-entry.ts'
vi.mock('node:os', () => ({
networkInterfaces: () => ({
lo0: [
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
],
en0: [
{ family: 'IPv6', internal: false, address: 'fe80::1' },
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
],
en1: [
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
],
utun0: undefined,
}),
}))
describe('resolveLanTrust', () => {
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
})
it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
})
})

View File

@@ -50,6 +50,9 @@
{
"path": "../../packages/client/ui-models"
},
{
"path": "../../packages/client/ui-permission"
},
{
"path": "../../packages/client/locale"
},

View File

@@ -0,0 +1,93 @@
// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history
// fixture (zero model calls) and pins the settled conversation aria after the
// user/assistant footers are focus-revealed — the surface package jsdom tests
// cannot substitute for (docs/testing.md snapshot rule).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url))
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
// a new recording (workspace-management / sidebar-scrollbar pattern).
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'message-actions-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
describe('web e2e: message IconActions and clocks on settled history', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). User has three actions; each finalized assistant
// text node has copy + branch.
const copyButtons = page.getByRole('button', { name: '复制' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await copyButtons.first().focus()
await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1)
}, 60_000)
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', {
name: '选择模型,当前 deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
await page.getByRole('button', { name: '复制' }).first().focus()
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -389,6 +389,11 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
.split(base).join('{{workspace}}')
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
// Message IconActions clocks widen by calendar day/year; collapse every
// shape so goldens stay stable across midnight and year boundaries.
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
}
/**

View File

@@ -119,6 +119,10 @@ it('projects titles and routes the next turn through the selected model in the b
await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
const revised = titleSurfaces(revisedLabel)
// fx-alpha carries the fixture's resident answerable approval, so the
// approval panel has taken over the composer (the real takeover behavior);
// answer it to restore the composer chrome before asserting the model seat.
fireEvent.click(await screen.findByRole('button', { name: '允许一次' }))
const modelTrigger = await screen.findByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash推理等级 High',
})

View File

@@ -1,21 +1,30 @@
- banner:
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
- text: "Think The user wants me to write a single `run_code` program that:"
- button:
- img
- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK
- button
- text: Read missing.txt
- img
- text: Code Run bash echo and catch missing file read
- img
- text: Bash Echo CODE_ROUND_OK Read
- button "missing.txt"
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- img
- text: Think The program ran successfully. Let me now reply DONE as instructed.
- paragraph: DONE
@@ -23,10 +32,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,7 +1,6 @@
- banner:
- navigation "Session hierarchy":
- button "Use only Cordis tools. First" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -13,14 +12,16 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to:":
- img
- img
- text: "Think The user wants me to:"
- button:
- img
- img
- text: Inspect temporary
- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
- img
- img
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
- button [expanded]:
@@ -29,12 +30,15 @@
- button "复制"
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
- img
- img
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
- button:
- img
- img
- text: Unmount temporary Plugin dyn-1
- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
- img
- img
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
- paragraph: CORDIS_UI_DONE
@@ -42,7 +46,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,17 +1,25 @@
- banner:
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img
- text: Think The user wants me to run a simple bash command and reply with "DONE".
- text: Echo the test string
- img
- text: Bash Echo the test string
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
- img
- img
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
- paragraph: DONE
@@ -19,10 +27,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,3 +1,4 @@
- button "New session"
- button "Collapse sidebar":
- img
- button "New session":
@@ -27,11 +28,13 @@
- textbox "Describe what you want to build"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 详情

View File

@@ -1,13 +1,19 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with the single word LIGHTHOUSE and stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- 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
@@ -15,10 +21,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,21 +1,28 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- paragraph: partial
- text: 已停止 0 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,19 +1,26 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,13 +1,19 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
@@ -15,10 +21,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -0,0 +1,52 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
- button "复制":
- img
- tooltip "复制"
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- img
- text: Read
- button "a.txt"
- img
- text: Read
- button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]

View File

@@ -1,7 +1,6 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -14,12 +13,15 @@
- button "编辑":
- img
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
- img
- img
- text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
- paragraph: DONE
@@ -27,10 +29,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,19 +1,27 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答1 题)"
- button "▸ 问题内容"
- text: 请在原客户端处理web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps
- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]

View File

@@ -1,19 +1,27 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
- paragraph: Great, let's move forward. BANANA!
@@ -21,10 +29,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -0,0 +1,23 @@
- dialog "选择工作区目录":
- heading "选择工作区目录" [level=2]
- navigation:
- button "主目录"
- img
- button "browse-golden"
- button "编辑路径"
- list:
- listitem:
- button "alpha":
- img
- text: alpha
- img
- listitem:
- button "beta":
- img
- text: beta
- img
- button "新建文件夹":
- img
- text: 新建文件夹
- button "取消"
- button "打开"

View File

@@ -37,6 +37,15 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
// Dual-face host package: its browser half fills the directory-flow holes
// (the same composition row apps/cli mounts for the node-side backend).
{
id: '@deepseek-ai/dsh-host-directory-picker-browse',
dir: '../host/directory-picker-browse',
url: '/plugins/directory-picker-browse.js',
rev: 'fx',
inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
},
]
const bundles = new Map(PLUGINS.map(plugin => [
@@ -174,6 +183,37 @@ it('locks the composer in the New Session view state until a Workspace is chosen
`)
})
it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
boot('?fixture=empty')
await findLockedComposer()
fireEvent.click(workspaceChip())
const menu = await screen.findByRole('menu')
// The composed flow package occupies the directory-flow hole, so the
// picking affordance is present (no advertised-kind read exists anymore).
expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
.toEqual(['Open local folder…', 'Create a new workspace'])
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
// The browse occupant renders the Select Workspace Directory dialog at the
// fixture home; select Documents, advance into project, and adopt it.
const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
// Row targeting goes through the visible label text: listitem accessible-name
// computation differs across dom-accessibility-api environments, while the
// row's name span is stable (clicks bubble to the row button).
fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
// Open disables while the selection's child listing is in flight; wait for
// the enabled state or the click lands on a dead button on slow runners.
await waitFor(() => {
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: '打开' }).disabled).toBe(false)
}, { timeout: 10_000 })
fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
await findHeroComposer()
await waitFor(() => {
expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')
})
})
it('selects the recent Workspace and opens its blank Session on first load', async () => {
boot('?fixture')

View File

@@ -13,8 +13,8 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
@@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i
// spec needs any one cold session row, not new recorded content.
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
const SEED_ID = 'workspace-management-web-e2e'
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
@@ -30,14 +31,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let pickedDirectory: string | null = null
/**
* Drive the in-app browser to a directory via its path-edit affordance,
* confirm it, and wait for the adoption to settle host-side (workspace
* registered + the flow's New-Session agent up), so later test steps can't
* race the in-flight blank-session attach.
*/
async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
const agentsBefore = scaffold.ctx.agents.list().length
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(path)
await dialog.getByLabel('编辑路径').press('Enter')
await dialog.getByRole('button', { name: '打开' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(path),
{ timeout: 10_000 },
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; the reuse path (same
// canonical cwd already has a blank session) creates no agent, so callers
// opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
}
}
beforeAll(async () => {
scaffold = await launchWebScaffold({})
scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({
rpcId: request.rpcId,
result: { ok: true, value: { path: pickedDirectory } },
})
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
@@ -137,14 +164,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
// Register the scaffold's existing project directory through the real UI.
pickedDirectory = scaffold.workspaceCwd
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
).not.toBeUndefined()
await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
await workspace.attachSession(SessionId(SEED_ID))
@@ -200,9 +220,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
pickedDirectory = scaffold.workspaceCwd
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await openLocalFolder(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
@@ -272,9 +290,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
pickedDirectory = oldPath
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await openLocalFolder(oldPath)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(oldPath),
{ timeout: 10_000 },
@@ -330,6 +346,42 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('matches the directory-browser dialog aria golden at a staged directory', async () => {
// A staged subtree under the scaffold cwd keeps the listing deterministic
// (normalizeAria scrubs the cwd), and pointing the in-process host's HOME
// at the cwd collapses the breadcrumb ancestry into the Home crumb — no
// machine-specific path segments or real $HOME contents enter the golden.
const staged = join(scaffold.workspaceCwd, 'browse-golden')
await mkdir(join(staged, 'alpha'), { recursive: true })
await mkdir(join(staged, 'beta'), { recursive: true })
// homedir() reads HOME on POSIX and USERPROFILE on Windows: root both
// at the scaffold cwd so the golden's ancestry collapses everywhere.
const realHome = process.env.HOME
const realUserProfile = process.env.USERPROFILE
process.env.HOME = scaffold.workspaceCwd
process.env.USERPROFILE = scaffold.workspaceCwd
try {
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(staged)
await dialog.getByLabel('编辑路径').press('Enter')
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
await dialog.getByRole('button', { name: '取消' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
} finally {
if (realHome === undefined) delete process.env.HOME
else process.env.HOME = realHome
if (realUserProfile === undefined) delete process.env.USERPROFILE
else process.env.USERPROFILE = realUserProfile
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Expand Ungrouped to reveal the seeded session row, then dwell on it
@@ -361,8 +413,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.warnings).toEqual([])
// This spec mints no fixture directory contents of its own; the seed it
// reuses is owned (and inventory-guarded) by seeded-history.
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep'])
// The directory-browser aria golden is this spec's one owned artifact;
// the seed it reuses is owned (and inventory-guarded) by seeded-history.
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
})
})

View File

@@ -34,7 +34,8 @@
"tests/seeded-history.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/code-mode-round.e2e.ts",
"tests/cordis-tool-round.e2e.ts"
"tests/cordis-tool-round.e2e.ts",
"tests/message-actions.e2e.ts"
],
"references": [
{