Merge master at 35eeb4a112

This commit is contained in:
pku-xht
2026-08-10 20:25:58 +08:00
879 changed files with 11605 additions and 2942 deletions

View File

@@ -46,6 +46,8 @@ flowchart LR
cfg --> plugin_dsh_base_llm_pi_ai
plugin_dsh_base_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
cfg --> plugin_dsh_base_session_persistence_jsonl
plugin_dsh_base_attachment_local["attachment-local<br/>@deepseek-ai/dsh-attachment-local"]
cfg --> plugin_dsh_base_attachment_local
plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_dsh_base_session_query_sqlite
plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
@@ -187,6 +189,7 @@ flowchart LR
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `session-projection` | `@deepseek-ai/dsh-session-projection` |
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |

View File

@@ -70,17 +70,15 @@
# ── background tasks ────────────────────────────────────────────────────────
- id: tasks
name: cordis:group
group: true
isolate:
tasks: true
config:
- id: tasks-local
name: '@deepseek-ai/dsh-tasks-local'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# Only the model-facing controls. The task REGISTRY stays on the host plane:
# its producers sit outside any realm this file could put it in — `tool-bash`
# above resolves it with `ctx.get`, and an entry-local realm here is invisible
# to every sibling row, so `run_in_background` would answer "background tasks
# unavailable" while these controls sat in the catalog. The registry is keyed by
# owning agent anyway, so one host instance serves every session. What a preset
# chooses is whether its agent can collect and stop background work at all.
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# ── skills ──────────────────────────────────────────────────────────────────
@@ -124,7 +122,7 @@
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.

View File

@@ -64,17 +64,15 @@
# ── background tasks ────────────────────────────────────────────────────────
- id: tasks
name: cordis:group
group: true
isolate:
tasks: true
config:
- id: tasks-local
name: '@deepseek-ai/dsh-tasks-local'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# Only the model-facing controls. The task REGISTRY stays on the host plane:
# its producers sit outside any realm this file could put it in — `tool-bash`
# above resolves it with `ctx.get`, and an entry-local realm here is invisible
# to every sibling row, so `run_in_background` would answer "background tasks
# unavailable" while these controls sat in the catalog. The registry is keyed by
# owning agent anyway, so one host instance serves every session. What a preset
# chooses is whether its agent can collect and stop background work at all.
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# ── goals ───────────────────────────────────────────────────────────────────
@@ -105,7 +103,7 @@
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.

View File

@@ -81,13 +81,13 @@ When a preset genuinely owns a service, wrap the provider **and every consumer t
A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing.
Registry-shaped host capabilities need no realm at all: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally.
Host capabilities exposed through registries need no realm: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally.
## Verifying a change
Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it.
To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself.
To check a preset you authored, re-read the files and validate these fields: the top level is a YAML list, every row is a map with a `name`, every group carries its own list, and service-publishing rows sit behind an `isolate` realm. The settings page's preset roster validates the same fields and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself.
`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.

View File

@@ -22,8 +22,16 @@
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
#
# `run_in_background` is off because this preset mounts no `tool-tasks`. The
# host registry already refuses a start for an owner no attached control
# surface serves, so this is not the safety boundary — it is the model-facing
# one: an agent that could never collect a task should not be offered the
# parameter at all, and disabling it drops the parameter from the schema.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
config:
enableRunInBackground: false
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'

View File

@@ -63,17 +63,15 @@
# ── background tasks ────────────────────────────────────────────────────────
- id: tasks
name: cordis:group
group: true
isolate:
tasks: true
config:
- id: tasks-local
name: '@deepseek-ai/dsh-tasks-local'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# Only the model-facing controls. The task REGISTRY stays on the host plane:
# its producers sit outside any realm this file could put it in — `tool-bash`
# above resolves it with `ctx.get`, and an entry-local realm here is invisible
# to every sibling row, so `run_in_background` would answer "background tasks
# unavailable" while these controls sat in the catalog. The registry is keyed by
# owning agent anyway, so one host instance serves every session. What a preset
# chooses is whether its agent can collect and stop background work at all.
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# ── skills ──────────────────────────────────────────────────────────────────
@@ -117,7 +115,7 @@
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.

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: bb3e1d77f00e03f6a21f4da3c04994f208e84293
README.zh.md: 3802f940ef6663ec215cf09293a9edda5ea4c512
README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91
README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70

View File

@@ -43,7 +43,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
## Web alias
`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
```sh
dsh web
@@ -61,7 +61,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback.
`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback.
## Shared deployment behavior

View File

@@ -43,7 +43,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
## Web 别名
`dsh web``--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host``--port``--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR热模块替换接收器若要无刷新更新客户端 bundle还需单独运行 `pnpm run dev:web` watcher。
`dsh web``--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host``--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR热模块替换接收器若要无刷新更新客户端 bundle还需单独运行 `pnpm run dev:web` watcher。
```sh
dsh web

View File

@@ -48,7 +48,6 @@ interface WebInvocation {
host?: string
port?: number
dev: boolean
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence. */
trustedHosts?: string[]
}
@@ -70,7 +69,6 @@ interface WebOptions {
host?: string
port?: string
dev?: boolean
workspaceRoot?: string
trustedHost?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
@@ -140,7 +138,7 @@ Examples:
resolved = { mode: 'profile', profile, patches }
})
/** Reject parent options that crossed a subcommand boundary. */
/** Reject parent options supplied before a subcommand. */
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{
profile?: string
@@ -176,7 +174,6 @@ Examples:
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
@@ -196,8 +193,8 @@ Examples:
// dropping them would print a tree that differs from the same
// invocation's boot.
if (options.host !== undefined || options.port !== undefined || options.dev === true
|| options.workspaceRoot !== undefined || options.trustedHost !== undefined) {
program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)')
|| options.trustedHost !== undefined) {
program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)')
}
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
return
@@ -211,7 +208,6 @@ Examples:
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
}
})

View File

@@ -14,8 +14,8 @@ export interface ProcessShutdown {
/**
* Create one process-exit controller around an application disposer.
* @param dispose - Whole-application teardown that resolves at quiescence.
* @param forceExit - Forced process exit boundary, replaceable by tests.
* @param complete - Natural process completion boundary, replaceable by tests.
* @param forceExit - Function that exits the process immediately, replaceable by tests.
* @param complete - Function that records the natural completion code, replaceable by tests.
* @param timeoutMs - Grace before forced exit, replaceable by tests.
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
*/

View File

@@ -1,7 +1,7 @@
/**
* `dsh web` — the browser-surface alias over the profile boot: `--profile web`
* plus the Web flag family (`--host/--port/--dev/--workspace-root/
* --trusted-host`), each flag becoming a patch over the composed profile
* plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag
* becoming a patch over the composed profile
* tree. All web runtime glue (dist serving, prompt section, URL line) lives
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
* flag patches and the LAN-trust snapshot.
@@ -59,7 +59,6 @@ export interface WebFlags {
host?: string
port?: number
dev: boolean
workspaceRoot?: string
trustedHosts?: string[]
}
@@ -83,7 +82,6 @@ function deriveWebFlagPatches(
}
if (flags.host !== undefined) put('webserver', 'host', flags.host)
if (flags.port !== undefined) put('webserver', 'port', flags.port)
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
if (trustedHosts.length > 0) {
@@ -122,8 +120,8 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
}
/**
* Serve the browser UI from the web profile. Host/port/workspace-root flags
* are passed through only when given (absent, the composed profile values
* Serve the browser UI from the web profile. Host/port flags are passed
* through only when given (absent, the composed profile values
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
* every boot. The URL line is printed by the web-app bundle's runtime row
* after Loader settlement.

View File

@@ -33,8 +33,8 @@ describe('parseDshArgs', () => {
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' })
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
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', patches: [] })
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] })
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
})

View File

@@ -1,6 +1,6 @@
/**
* The third-party memory examples stay config-only. This suite parses every
* checked-in overlay, verifies its pin/transport/secret boundary, then replaces
* checked-in overlay, verifies its package pin, transport, and secret handling, then replaces
* only the upstream endpoint with the package-owned keyless MCP fixture and
* proves the real Cordis Loader discovers a tool through the generic bridge.
*/
@@ -81,7 +81,7 @@ async function waitForTool(ctx: Context, name: string): Promise<void> {
}
describe('third-party memory MCP example overlays', () => {
it.each(examples)('parses $file with the documented generic boundary', (contract) => {
it.each(examples)('parses $file with the documented generic plugin fields', (contract) => {
const file = resolve(exampleDir, contract.file)
const source = readFileSync(file, 'utf8')
const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))

View File

@@ -5,7 +5,7 @@ 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
* shape as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the
* executable and arguments as `bin/dsh` and the root `dsh`/`demo:web` scripts) 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

View File

@@ -44,9 +44,15 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
return page.getByRole('dialog', { name: '设置' })
}
/** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */
/** Tokenize the lane-owned preset root after general aria normalization. */
function withPresetRoot(snapshot: string): string {
return snapshot.split(userRoot).join('{{presetRoot}}')
const rootSuffix = `/${userRoot.split('/').pop()!}`
return snapshot.split('\n').map((line) => {
const rootStart = line.indexOf(rootSuffix)
if (rootStart === -1) return line
const pathStart = line.lastIndexOf(' ', rootStart) + 1
return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
}).join('\n')
}
beforeAll(async () => {

View File

@@ -11,6 +11,7 @@
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { fileURLToPath } from 'node:url'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
@@ -29,6 +30,30 @@ const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'agent-preset-selection-web-e2e'
/** A project skill only a preset that mounts `skill-local` can discover. */
const SKILL_NAME = 'preset-catalog-demo'
/**
* Seed one project skill under the connected workspace.
*
* Local skill discovery is a PRESET row, so this file is visible through
* `standard` and invisible through `minimal` — which makes the '/' menu's
* skill group a statement about the session's composition.
* @param workspaceCwd - the scaffold's temp project parent.
*/
async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> {
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), [
'---',
`name: ${SKILL_NAME}`,
'description: Prove the slash catalog follows the session composition',
'---',
'',
'Body.',
'',
].join('\n'))
}
/**
* A settled one-turn session with no model content: this lane asserts chrome
@@ -53,6 +78,35 @@ function seedLog(): string {
].join('\n')
}
/**
* The preset the host reports for the blank session the workspace connect
* produced. Addressed by id rather than by scanning the serialized list: the
* seeded session records `minimal` too, so a substring match over the whole
* list answers before the switch has landed.
* @param baseUrl - the scaffold's origin.
* @returns the live session's preset, or undefined before it is listed.
*/
async function livePreset(baseUrl: string): Promise<string | undefined> {
const response = await fetch(`${baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
}
return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
}
/** Every option label the trigger menu currently lists. */
async function menuOptions(page: Page): Promise<string[]> {
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
return await menu.getByRole('option').allTextContents()
}
describe('web e2e: agent-preset selection', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -67,6 +121,7 @@ describe('web e2e: agent-preset selection', () => {
// records `minimal` is what makes the header label a claim about the
// session rather than an echo of the current default.
await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
await seedWorkspaceSkill(scaffold.workspaceCwd)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
@@ -114,21 +169,47 @@ describe('web e2e: agent-preset selection', () => {
// The chip stages; the blank session the workspace connect produced is
// what the stage lands on. The host's own answer is what comes back.
await expect.poll(async () => {
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } }
}
return JSON.stringify(body.result.value?.sessions ?? body.result)
}, { timeout: 15_000 }).toContain('minimal')
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal')
})
it('re-reads the slash catalog through the composition the switch installed', async () => {
// Continues the previous case: the chip has already applied `minimal` to
// the blank session, and this one reads the menu that switch left behind.
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
const composer = page.locator('textarea:enabled').last()
// `minimal` mounts neither the compaction group nor plan mode nor local
// skill discovery, so the catalog the composer warmed under the
// deployment default must not survive the switch.
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onMinimal = await menuOptions(page)
expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
// The host-plane commands and the client's own contribution are the
// floor: they belong to no preset and never move.
expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true)
expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
await composer.fill('')
// Switching back up reaches the host at all — the chip compares the pick
// against its list row, so a row that never reprojected the first switch
// answers "already standard" and sends nothing — and restores the catalog
// instead of leaving the session reading the narrower composition.
await page.getByRole('button', { name: '极简模式' }).click()
await page.getByRole('menuitem', { name: /^标准模式/ }).first().click()
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onStandard = await menuOptions(page)
expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
await composer.fill('')
}, 90_000)
it('labels a resumed session with the preset it was created under', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
// The seeded session's cwd is the scaffold root rather than the connected

View File

@@ -35,9 +35,9 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
// Irreducible payload: the command has to be long enough to pass the card's
// height cap, which is the only shape that reproduces an action row pushed off
// screen. Unrelated tokens, not a repeated word — the model compresses a
// repeated word into `printf 'alpha %.0s' {1..400}` when recording, and a
// height cap, which is the only command length that reproduces an action row pushed off
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
// short command proves nothing here. The formula keeps the source small; the
// model receives the expanded literal it has to put in the command.
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')

View File

@@ -70,7 +70,12 @@ let unmount: (() => void) | undefined
export function installAssembledBootEnv(): void {
beforeEach(() => {
localStorage.clear()
localStorage.setItem('dsh.locale', 'en')
// The locale service derives its provisional locale from the browser and
// takes an explicit choice only from Host settings, which this lane's
// fixture transport does not serve; pinning the navigator is what selects
// English here.
Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
document.title = 'DeepSeek Harness'
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
@@ -88,6 +93,11 @@ export function installAssembledBootEnv(): void {
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
// Deleting the own properties uncovers jsdom's own accessors again
// (Navigator declares both readonly, hence the erased receiver).
const ownNavigator = navigator as unknown as Record<string, unknown>
delete ownNavigator.languages
delete ownNavigator.language
vi.unstubAllGlobals()
})
}

View File

@@ -186,7 +186,7 @@ describe('web e2e: long Chat interaction contract', () => {
const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end' && event.data.turn === BRANCH_TURN
))
if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`)
const expectedUserText = textContent(branchUserEvent.data.content)
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)

View File

@@ -1,7 +1,7 @@
// Opt-in browser benchmark for high-cardinality workspace and history
// rendering. It reports measurements without timing assertions because host
// speed is not a correctness contract; structural assertions keep the load
// shape from silently shrinking.
// speed is not a correctness contract; structural assertions keep the number
// of workspaces and history entries from silently shrinking.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

View File

@@ -64,12 +64,12 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
}).join('\n')
/**
* A draft ending in a newline: the shape where the two layers reserve their
* A draft ending in a newline, where the two layers reserve their
* final line box on different terms. A textarea keeps one for the caret after a
* final newline; `white-space: pre-wrap` collapses a text node's trailing
* newline and generates none. The hidden auto-grow mirror carries the newline
* and so decides the height for both, which is why the backdrop needs no
* padding of its own — but only a draft of this shape can show it.
* padding of its own — but only a draft with a trailing newline can show it.
*/
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
@@ -381,7 +381,7 @@ describe('web e2e: composer draft scrolling', () => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
// Ending in a newline is the shape the engines disagree on: the caret
// The engines disagree when the draft ends in a newline: the caret
// lands on a line with nothing on it, where chromium reports no client
// rects at all for the collapsed position.
}, `\n${DRAFT}\n`)
@@ -401,7 +401,7 @@ describe('web e2e: composer draft scrolling', () => {
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
// The layers reserve a final line box on different terms, so this shape is
// The layers reserve a final line box on different terms, so the trailing-newline case is
// the one that separates a height every layer agrees on from a box measured
// one line short of the caret's own last position.
const input = page.locator('textarea:enabled').first()
@@ -453,7 +453,7 @@ describe('web e2e: composer draft scrolling', () => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
// The ordinary shape — not ending in a newline so the collapsed branch
// The ordinary case, without a trailing newline, so the collapsed branch
// of the reveal keeps a real engine under it; the case above owns the
// after-newline branch.
}, `\n${DRAFT}`)

View File

@@ -42,7 +42,7 @@ function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first()
}
/** Render the two boundary affordances without platform-dependent coordinates. */
/** Render the two column-resize handles without platform-dependent coordinates. */
async function handleSnapshot(page: Page): Promise<string> {
const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
elements.map(element => ({

View File

@@ -0,0 +1,136 @@
// @vitest-environment jsdom
// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
// Opens the fixture history session whose turn 72 carries an image in BOTH a
// user message and an assistant message, and pins the product surfaces: the
// history ImageGallery loading real fixture bytes through the authorized
// sessions.attachment route, the double-click ImageLightbox, and the composer
// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */
async function openFixtureSession(): Promise<void> {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const group = (await within(tree).findAllByText('fixture'))
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
.find(el => el?.getAttribute('aria-expanded') !== null)
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
if (group.getAttribute('aria-expanded') === 'false') {
fireEvent.click(within(group).getByText('fixture'))
await waitFor(() => {
expect(group.getAttribute('aria-expanded')).toBe('true')
})
}
const session = await within(tree).findByText('Fixture 历史会话')
fireEvent.click(session)
await waitFor(() => {
expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0)
}, { timeout: 10_000 })
}
it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => {
mountAssembledApp()
await openFixtureSession()
// Both the user-side (align=end) and assistant-side (align=start) galleries
// load real fixture bytes over sessions.attachment. jsdom provides
// createObjectURL, so this environment MUST take the object-URL path — a
// data: src here would mean the fallback ran where it should not.
await waitFor(() => {
if (document.querySelector('[data-align="end"] img') === null
|| document.querySelector('[data-align="start"] img') === null) {
throw new Error('history image galleries missing')
}
}, { timeout: 10_000 })
const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)]
.map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] }))
expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(`
{
"assistant": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
"user": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
}
`)
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
// Double-click opens the original-size lightbox; Escape/close dismisses it.
const frame = userImage.closest('button')
if (frame === null) throw new Error('image frame button missing')
fireEvent.doubleClick(frame)
const lightbox = await screen.findByRole('dialog')
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull()
})
})
it('accepts pasted images into the composer rail in order and removes them', async () => {
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
// Image-only send arming is pinned at package level (input-bar.spec.tsx);
// this assembled lane pins the intake chain over the built graph.
const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
getData: () => '',
},
})
// The rail is an accessible group holding the draft thumbnail (queried via
// DOM: jsdom's a11y-visibility computation hides the composer subtree).
const rail = await waitFor(() => {
const el = document.querySelector('[role="group"][aria-label="Pending images"]')
if (el === null) throw new Error('attachment rail missing')
return el
}, { timeout: 5_000 })
expect([...rail.querySelectorAll('img')].map(img => ({
alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0],
}))).toMatchInlineSnapshot(`
[
{
"alt": "pasted.png",
"scheme": "blob",
},
]
`)
const second = new File([new Uint8Array([137, 80, 78, 71])], 'second.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => second }],
getData: () => '',
},
})
await waitFor(() => {
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt')))
.toEqual(['pasted.png', 'second.png'])
})
const remove = [...rail.querySelectorAll('button[aria-label^="Remove image"]')]
if (remove.length !== 2) throw new Error('remove buttons missing')
for (const button of remove) fireEvent.click(button)
await waitFor(() => {
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
})
})

View File

@@ -78,8 +78,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace;
// the read targets must live in that session cwd (pre-creation is safe:
// create-by-name adopts an existing directory).
// the read targets must live in that session cwd (pre-creation is safe
// because the picker adopts an existing directory by path).
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n')
@@ -164,8 +164,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
sessionId = await settled
}
await recordFixture(scaffold, sessionId!, SEED)
// Fixture honesty: the recording must carry the shape the replay
// scenarios assert on three calls in turn 1 and two closed turns.
// Fixture honesty: the recording must contain the events the replay
// scenarios assert on: three calls in turn 1 and two closed turns.
const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')

View File

@@ -1,7 +1,7 @@
// Keyless browser regression for pwsh UI parity with bash: a seeded session
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
// api-proxy recomputes presentation views from logged args/result content)
// must render as a bash-shaped terminal card with the parsed exit-status
// must render with the same terminal card layout as bash and show the parsed exit-status
// pill — not a generic console-fenced card. The seed is authored, not
// recorded: its header line carries no `cwd`
// field (seedSession writes the session cwd itself, and a Windows temp path
@@ -43,7 +43,7 @@ const HAS_PWSH = MODE === 'record' ? false : spawnSync(
{ encoding: 'utf8' },
).status === 0
describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => {
describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bash terminal-card layout', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -75,7 +75,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as b
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
await result.click()
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 })
// The tool row is expand-gated: the settled bash-shaped row carries the
// The tool row is expand-gated: the settled row uses the bash layout and carries the
// shell-family variant, and the terminal card lives in the expanded body.
const row = page.locator('[data-tool="pwsh"]').first()
await row.waitFor({ timeout: 15_000 })

View File

@@ -31,7 +31,7 @@ const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only shape that reproduces a
// needs option copy that WRAPS, which is the only text layout that reproduces a
// collapsed row painting its copy outside its own box.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
@@ -116,7 +116,7 @@ describe('web e2e: resident question composer round trip', () => {
return {
rows: rows.length,
spill: Math.max(...spill),
// Wrapped copy is the shape that overflows a collapsed row, and a
// Wrapped option text is what overflows a collapsed row, and a
// scrolling list proves the seat is genuinely capped. Without both,
// the spill assertion would hold vacuously.
wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,

View File

@@ -251,6 +251,8 @@ export interface LaunchOptions {
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
*/
remoteAuthority?: string
/** Reuse an existing harness home so a second Host can verify user settings across origins. */
harnessHome?: string
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -297,16 +299,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
// Skill discovery is model-visible input, and its roots now resolve inside a
// PRESET — a subtree this lane's include patches cannot reach, because the
// roster mounts it directly per session rather than as a row of the booted
// tree. The row's documented fallback is the environment, so pin that: the
// whole scaffold lifetime, not just the boot, since presets mount when a
// session is created. Without this a developer's real ~/.dsh/skills silently
// enters replay requests and goldens while CI sees none.
// enters replay requests and goldens while CI sees none. `DSH_HOME` follows
// the resolved harness home so a scaffold sharing another's home — the
// cross-port persistence scenario — pins the same roots the settings and
// credentials rows were configured with.
const skillRootEnvironment = {
DSH_HOME: join(workspaceCwd, '.dsh-home'),
DSH_HOME: harnessHome,
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
}
@@ -443,7 +448,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
let replayHandle: ReplayHandle | undefined
try {
process.chdir(workspaceCwd)
// The production resolution shape: an empty profile root inside the temp
// The production module-resolution setup: an empty profile root inside the temp
// harness home, with bare plugin names resolving through the flat module
// fallback the launcher heals under <home>/profiles.
healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome)
@@ -621,7 +626,7 @@ export function fixtureUserPrompts(fixtureText: string): string[] {
* through the REAL backend API (throwaway Context + SessionStore + JSONL
* plugin — the semantic-checkpoint precedent), never raw file writes: no
* knowledge of bucket hashing, filename encoding, or compression, and
* malformed shapes fail loud at seed time. The fixture's tokenized identity
* malformed session events fail loud at seed time. The fixture's tokenized identity
* ({{sessionId}}/{{cwd}}) is realized for this world before parsing.
* @param scaffold - the target scaffold.
* @param fixtureText - raw recorded session.jsonl contents.
@@ -725,7 +730,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
// between local worktrees and CI scratch directories.
.replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2')
// Message IconActions clocks widen by calendar day/year; collapse every
// shape so goldens stay stable across midnight and year boundaries.
// format so goldens stay stable across midnight and year changes.
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')

View File

@@ -4,7 +4,7 @@
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 67) renders in
// the assembled application. The built-boot smoke proves the graph boots but
// carries no behavior assertions by contract; this is the assembled-output check
// intentionally carries no behavior assertions; this is the assembled-output check
// that a broken SearchRow registration or a dropped card would fail — the
// per-package suites bench over src and cannot see the bundled wiring.
//
@@ -13,7 +13,7 @@
// fixture, not harvested from a live model. The recovery-footer arm is a pure
// derivation over the result view, pinned at every render site by the
// ui-conversation suite; here the fixture turn exercises the assembled card
// shape and its cap.
// fields and its cap.
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -24,7 +24,7 @@ const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-
installAssembledBootEnv()
/** Normalize a rendered search card to a stable text shape: the kind, the banner
/** Normalize a rendered search card to stable text fields: the kind, the banner
* summary, each file header (path + count), each visible match line, the expand
* control label, and the recovery footer. */
function cardShape(root: Element): string {
@@ -63,7 +63,7 @@ describe('assembled search card', () => {
}, { timeout: 10_000 })
// `data-tool` sits on the ToolRow root; the collapsed row is the expand
// toggle. Click it so the card and its recovery footer mount, then shape the
// toggle. Click it so the card and its recovery footer mount, then serialize the
// whole row (the card lives inside ToolRow's body wrapper).
const grepRow = document.querySelector('[data-tool="grep"]')!
act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) })

View File

@@ -3,12 +3,11 @@
// else covers: sidebar cold listing, the implicit resume/attach inside the
// history RPC, history-page tool views, and the client's log-ordered transcript
// events — with ZERO model calls in replay (no replay fixture; a stray stream
// fails loud on the open llm seam). The cold session also carries the one
// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds
// into its checkpoint, while an Access-chip pick later runs `/permission` on
// the host. The seed is a recorded
// fixture under the
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// fails loud on the open llm seam). The cold session also carries keyless
// command-row surfaces: the seeded manual `/compact` lifecycle folds into its
// checkpoint, an Access-chip pick later runs `/permission` on the host, and
// `/feedback` pins its expandable correlation ids. The seed is a recorded
// fixture under the same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// live through the composer (real read tool against seeded workspace files)
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
@@ -32,9 +31,9 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url))
// The command-row golden: the same conversation after one /permission switch,
// which is the only surface that shows a settled command row's copy.
// Command-row goldens over the same conversation after direct host commands.
const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url))
const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/feedback-row.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'seeded-history-web-e2e'
@@ -103,7 +102,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
})
// Load-bearing exactness: the projections subtract this count verbatim, so
// it must equal what the host's fold prices for these nodes. The estimator
// prices message CONTENT only, so a minimal wrapper per storage shape is
// prices message CONTENT only, so a minimal wrapper for each stored event format is
// exact — pre-identity rows carry bare `content` (the persistence read path
// upgrades them), a current row carries the full `message` envelope.
const priceRow = (row: (typeof events)[number]): number => {
@@ -170,7 +169,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
},
})
// The persistence seed helper requires a terminal turn/end. Keep the manual
// command standalone, then add a closed zero-step fixture boundary after it.
// command standalone, then add a closed zero-step turn after it.
const closureTurn = lastTurn + 1
at({ type: 'turn/start', data: { turn: closureTurn } })
at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } })
@@ -187,8 +186,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace
// (the composer's default draft name); the read-tool targets must live in
// that session cwd. Pre-creating the directory is safe: create-by-name
// adopts an existing directory.
// that session cwd. Pre-creating the directory is safe because the picker
// adopts an existing directory by path.
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
@@ -446,6 +445,44 @@ describe('web e2e: seeded history renders through cold resume', () => {
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('reports full feedback correlation ids in an expandable two-line row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-feedback-row'))
const previousDshHome = process.env.DSH_HOME
process.env.DSH_HOME = scaffold.harnessHome
try {
const input = page.locator('textarea').first()
await input.fill('/feedback the diff view is unreadable')
await input.press('Enter')
const row = page.locator('[data-variant="others"]').filter({
hasText: `Feedback recorded for session ${SEED_ID}`,
})
await row.waitFor({ timeout: 10_000 })
const disclosure = row.locator('[data-expandable]')
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
const done = agent.session.events.filter(event => event.type === 'command/done').at(-1)
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(extraLine).toBeUndefined()
const userId = userLine?.slice('User: '.length)
if (userId === undefined) throw new Error('feedback command omitted the user id')
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
.split(userId).join('{{userId}}')
await compareOrRefreshGolden(FEEDBACK_ROW_EXPECTED, snapshot, MODE)
} finally {
if (previousDshHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousDshHome
}
}, 60_000)
it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => {
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
@@ -473,6 +510,6 @@ describe('web e2e: seeded history renders through cold resume', () => {
// stream would have failed the turn loudly. Cleanliness pins the wire.
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md'])
})
})

View File

@@ -1,11 +1,10 @@
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token +
// browser theme-color metadata)
// the Language row (settings-scoped localization + persisted dsh.locale),
// the busy-state Enter preference, plus Permission as the persisted default
// for subsequently created sessions.
// the Language row and busy-state Enter preference (both Host-backed), plus
// Permission as the persisted default for subsequently created sessions.
// Zero model calls: everything is pure client + persistence state on a blank
// frame, so there is no fixture and a stray stream would fail loud on the
// open llm seam.
@@ -153,23 +152,24 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('flips the theme through the Appearance cubes and persists across reload', async () => {
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
interface ThemeState {
attr: boolean
background: string
stored: string | null
/** Pre-migration localStorage key; the Host-backed world never writes it. */
legacy: string | null
themeColor: string | null
themeColorCount: number
token: string
}
const readState = async (): Promise<ThemeState> => await page.evaluate(() => {
const readState = async (target: Page = page): Promise<ThemeState> => await target.evaluate(() => {
const metas = document.head.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
const computed = getComputedStyle(document.body)
return {
attr: document.body.hasAttribute('data-ds-dark-theme'),
background: computed.backgroundColor,
stored: localStorage.getItem('dsh.theme'),
legacy: localStorage.getItem('dsh.theme'),
themeColor: metas[0]?.content ?? null,
themeColorCount: metas.length,
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
@@ -193,27 +193,51 @@ describe('web e2e: settings modal and General preferences', () => {
const darkCube = dialog.getByRole('button', { name: '深色' })
expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
await darkCube.click()
// The full cascade: pressed state, persisted preference, body attribute,
// The full cascade: pressed state, Host-backed preference, body attribute,
// alias token flip — all from one real user gesture.
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
const dark = await readState()
expect(dark.attr).toBe(true)
expect(dark.stored).toBe('dark')
expect(dark.legacy).toBeNull()
expect(dark.token).not.toBe(light.token)
expectThemeColorSynchronized(dark)
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-theme:\n\s+preference: dark/)
await page.keyboard.press('Escape')
// Reload: the preference survives boot (restore + presenter initial apply).
// Reload: the preference survives the background Host read + presenter update.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
const reloaded = await readState()
expect(reloaded.attr).toBe(true)
expect(reloaded.stored).toBe('dark')
expect(reloaded.legacy).toBeNull()
expectThemeColorSynchronized(reloaded)
// A second live Host binds another ephemeral port but shares the same
// user-settings home. Its fresh origin has no theme localStorage and still
// converges to dark before the settings dialog opens.
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const secondTripwire = watchConsole(secondPage)
try {
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
await secondPage.emulateMedia({ colorScheme: 'light' })
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true)
const secondState = await readState(secondPage)
expect(secondState.legacy).toBeNull()
expectThemeColorSynchronized(secondState)
expect(secondTripwire.pageErrors).toEqual([])
expect(secondTripwire.warnings).toEqual([])
} finally {
await secondPage.close()
await second.close()
}
// `system` follows the emulated OS scheme (dark stays dark, light clears).
await page.getByRole('button', { name: '设置', exact: true }).click()
const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
@@ -233,7 +257,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('persists the busy-state Enter behavior across reload and restores Queue', async () => {
it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
@@ -241,7 +265,9 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: '排队发送' }).click()
await page.getByRole('menuitem', { name: '插话发送' }).click()
await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer')
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-conversation:\n\s+busyEnter: steer/)
await page.keyboard.press('Escape')
const warningStart = tripwire.warnings.length
@@ -251,15 +277,36 @@ describe('web e2e: settings modal and General preferences', () => {
await page.getByRole('button', { name: '设置', exact: true }).click()
const reloaded = page.getByRole('dialog', { name: '设置' })
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const secondTripwire = watchConsole(secondPage)
try {
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await secondPage.getByRole('button', { name: '设置', exact: true }).click()
await secondPage.getByRole('dialog', { name: '设置' })
.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
expect(secondTripwire.pageErrors).toEqual([])
expect(secondTripwire.warnings).toEqual([])
} finally {
await secondPage.close()
await second.close()
}
await reloaded.getByRole('button', { name: '插话发送' }).click()
await page.getByRole('menuitem', { name: '排队发送' }).click()
await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue')
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-conversation:\n\s+busyEnter: queue/)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('switches the settings surface language and persists dsh.locale', async () => {
it('persists the settings language across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const zhDialog = page.getByRole('dialog', { name: '设置' })
@@ -276,7 +323,9 @@ describe('web e2e: settings modal and General preferences', () => {
await enDialog.waitFor({ timeout: 10_000 })
expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en')
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/locale:\n\s+preference: en/)
// Reload keeps English; then restore zh so shared page state (and the
// other specs' 设置-anchored selectors + goldens) see the default again.
const warningStart = tripwire.warnings.length
@@ -285,24 +334,47 @@ describe('web e2e: settings modal and General preferences', () => {
acknowledgeReloadConnectionLoss(tripwire, warningStart)
const enTrigger = page.getByRole('button', { name: 'Settings' })
await enTrigger.waitFor({ timeout: 10_000 })
// A Chinese browser on another port still receives the explicit English
// preference from the shared Host settings document.
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const secondTripwire = watchConsole(secondPage)
try {
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await secondPage.getByRole('button', { name: 'Settings', exact: true }).click()
await secondPage.getByRole('dialog', { name: 'Settings' })
.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
expect(secondTripwire.pageErrors).toEqual([])
expect(secondTripwire.warnings).toEqual([])
} finally {
await secondPage.close()
await second.close()
}
await enTrigger.click()
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
await page.getByRole('menuitem', { name: '中文' }).click()
await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh')
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/locale:\n\s+preference: zh/)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('opens an English browser in English without any stored preference', async () => {
// A second page under a different browser language: nothing is persisted
// for it, so the settings surface must follow the browser rather than the
// product fallback the shared zh page shows.
// A fresh Host home has no locale preference, so its surface follows the
// browser rather than the product fallback.
const fresh = await launchWebScaffold({})
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
const enTripwire = watchConsole(enPage)
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
try {
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
await enPage.goto(fresh.baseUrl, { waitUntil: 'load' })
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
@@ -315,6 +387,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(enTripwire.warnings).toEqual([])
} finally {
await enPage.close()
await fresh.close()
}
}, 90_000)

View File

@@ -5,6 +5,7 @@
// surface itself.
import { tmpdir } from 'node:os'
import { afterEach, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
import { SessionId } from '@deepseek-ai/dsh-session'
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
@@ -90,7 +91,7 @@ it('assembles the shipped Web catalog with the confined access default', async (
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and
// keeps a future boundary test from being run inside /tmp — where an
// keeps a future sandbox-confinement test from being run inside /tmp — where an
// "escape" write succeeds by design and reads as a sandbox failure.
expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual(
expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]),
@@ -114,3 +115,64 @@ it('assembles the shipped Web catalog with the confined access default', async (
await commandHandle.dispose()
}
}, 120_000)
it('lets a preset producer reach the background-task registry', async () => {
scaffold = await launchWebScaffold()
const ctx = scaffold.ctx
const handle = await ctx.agents.create({
sessionId: SessionId('shipped-background-task'),
meta: { cwd: scaffold.workspaceCwd },
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
const signal = new AbortController().signal
// `tool-bash` is a preset row and `tasks` is a host registry; the producer
// resolves it with `ctx.get`, so a registry hidden behind a preset realm
// fails here — with every task control still listed in the catalog above.
const started = await ctx.tools.execute({
signal,
callId: CallId('shipped-bash-background'),
name: 'bash',
arguments: {
command: 'printf SHIPPED_BACKGROUND_OK',
description: 'shipped background probe',
run_in_background: true,
},
agent: handle.agent,
})
expect({ isError: started.isError, content: started.content }).toEqual({
isError: false,
content: [{ type: 'text', text: 'started background task bash-1' }],
})
// The control surface reads what the producer started: same registry, one
// owner. A per-preset registry would list nothing here even on success.
const listed = await ctx.tools.execute({
signal,
callId: CallId('shipped-task-list'),
name: 'task_list',
arguments: {},
agent: handle.agent,
})
expect(listed.isError).toBe(false)
expect(listed.content).toEqual([
{ type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string },
])
// The full round trip: the output a host-plane producer wrote is collected
// through a preset-plane control, which is the linkage the realm severed.
const collected = await ctx.tools.execute({
signal,
callId: CallId('shipped-task-output'),
name: 'task_output',
arguments: { task_id: 'bash-1', wait: true },
agent: handle.agent,
})
expect(collected.isError).toBe(false)
expect(collected.content).toEqual([
{ type: 'text', text: expect.stringContaining('SHIPPED_BACKGROUND_OK') as unknown as string },
])
} finally {
await handle.dispose()
}
}, 120_000)

View File

@@ -7,7 +7,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- img
- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":

View File

@@ -0,0 +1,53 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- 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. 7/25 {{clock}}"
- button "Copy":
- 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 "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- 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 "Copy":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
- img
- text: Context injection AGENTS.md
- img
- text: permission preset read-only
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]':
- img
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}"
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}"
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -19,10 +19,10 @@
- img
- text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
- status: Deep diving...
- text: "Interjection Interjection: include the word BANANA in your final reply."
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply."
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy":
- img
- textbox "Message the agent"

View File

@@ -21,10 +21,10 @@
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}"
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
- button "Copy":
- img
- paragraph: "Got it: BANANA and ORANGE."

View File

@@ -23,7 +23,7 @@
- img
- text: Ask question waiting
- status: Deep diving...
- text: "Interjection Interjection: include the word BANANA in your final reply."
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- region "Ready to continue?":

View File

@@ -21,7 +21,7 @@
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":

View File

@@ -17,11 +17,10 @@
// replacing those nodes.
//
// The round-trip against a loopback host is far too fast to observe, so this
// scenario HOLDS the `session.history` response open at the browser's network
// boundary and asserts the visible frame while it is in flight. That gate is
// what makes the assertions non-vacuous: without the phase exemption, the
// held window is exactly when `settling` would be painted and the composer
// hidden.
// scenario HOLDS the `session.history` response open in the browser's network
// handler and asserts the visible frame while it is in flight. That wait is
// what makes the assertions non-vacuous: without the phase exemption, the held
// window is exactly when `settling` would be painted and the composer hidden.
//
// Zero model calls: registering a workspace and opening its blank session are
// host RPCs with no model involvement. A stray stream would fail loud with

View File

@@ -446,7 +446,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
).toBe(3)
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
expect(await hierarchy.getByRole('button').count()).toBe(1)
await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1)
await compareOrRefreshGolden(
FORK_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),

View File

@@ -18,18 +18,17 @@ export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
export const ZH_BROWSER_LOCALE = 'zh-CN'
/**
* Open the standard browser-test page with English selected before client
* boot. This keeps role locators and goldens deterministic across localized
* component migrations; the scenarios asserting the Chinese surface bypass
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
* Open the standard browser-test page advertising English before client boot.
* This keeps role locators and goldens deterministic while leaving the Host
* settings document free to override the provisional browser-derived locale;
* scenarios asserting the Chinese surface advertise
* {@link ZH_BROWSER_LOCALE} instead.
* @param browser - Playwright browser owning the page.
* @param height - Viewport height; width is fixed to the lane baseline.
* @returns the initialized page.
*/
export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
const page = await browser.newPage({ viewport: { width: 1680, height } })
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
return page
return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' })
}
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */

View File

@@ -2,7 +2,7 @@
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the
// keyless FixtureApiClient transport, opens the fixture session, and pins the
// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`)
// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`)
// reaches — the `todo_write` tool row and the dock's plan strip.
//
// The row is pinned as three separate fields on purpose. `summary=` is the
@@ -20,7 +20,7 @@ const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel
installAssembledBootEnv()
/** Normalize the todo row and the plan strip to a stable text shape: the row's
/** Normalize the todo row and the plan strip to stable text fields: the row's
* title, its truncatable summary, its non-shrinking suffix, then the panel's
* per-status header and every list item with its status. */
function todoShape(row: Element, panel: Element): string {

View File

@@ -1,7 +1,7 @@
// Web e2e scenario: assistant IconActions belong to the settled answer, so
// they arrive with `turn/end` and not before. The recorded turn narrates in
// plain text before its tool call, which is the shape that would hand the
// footer to mid-turn narration for the seconds a tool runs and then move it
// plain text before its tool call, which is the event order that would show the
// footer beside mid-turn narration for the seconds a tool runs and then move it
// down. A `hang` sidecar on the SECOND model call parks the turn after the
// narration and the tool result are durable, so the running state is stable by
// construction rather than by timing; stopping from that park writes the

View File

@@ -31,8 +31,8 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo
const MODE = webSnapshotMode()
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
const SEED_ID = 'workspace-management-web-e2e'
// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled
// to that contract if the shared grace tuning changes.
// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them above
// that value if the shared setting changes.
const POINTER_TRANSIT_MS = 300
const POINTER_HOLD_MS = 600

View File

@@ -31,7 +31,7 @@ function rejectStandaloneServe(): Plugin {
* editing shell code re-hashes only index and returning clients keep the
* cached vendor chunk.
*
* Boundary invariant: every member must be react-free. A package that
* Every member must be React-free. A package that
* imports react/jsx-runtime must never be listed — rollup folds a module
* shared between the entry and a manual chunk into the manual chunk, so one
* react-importing member would drag the single shared react copy into
@@ -77,8 +77,8 @@ const BOOT_GRAMMAR_FILES: readonly string[] = [
const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf']
/**
* npm package name of a resolved module id (the segment after the LAST
* `node_modules/` pnpm nests the real package under an inner node_modules).
* npm package name of a resolved module id: the segment after the last
* `node_modules/`. pnpm nests the real package under an inner node_modules.
*/
function npmPackageOf(id: string): string | undefined {
const parts = id.split('/node_modules/')