Merge branch 'codex/disable-telemetry-default' into master

Resolved conflicts:
- packages/client/ui-settings-general/README.md, README.zh.md: kept PR
  opt-in telemetry description (DSH_TELEMETRY_MODE)
- scripts/snapshots/translation-prompt-v4: kept master's newer README
  structure snapshot
- i18n.yaml pairing records: resolved per file state
- pnpm-lock.yaml: regenerated
- Modify/delete conflicts (scaffold/telemetry, sdk-follow-up-capabilities):
  kept master deletions
This commit is contained in:
Chinesezjc
2026-08-12 17:27:37 +08:00
2021 changed files with 43956 additions and 23769 deletions

1
apps/web/.npmignore Normal file
View File

@@ -0,0 +1 @@
*.map

View File

@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.0.1-rc.1",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
@@ -16,7 +16,8 @@
"./package.json": "./package.json"
},
"files": [
"dist"
"dist",
"!dist/**/*.map"
],
"scripts": {
"build": "vite build",

View File

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

46
apps/web/tests/README.md Normal file
View File

@@ -0,0 +1,46 @@
# apps/web browser e2e
English | [中文](README.zh.md)
These tests boot the real web composition in-process and drive it with a real
Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and
the deliberate composition divergences from `dsh web` — are documented in
[`scaffold.ts`](scaffold.ts) and the
[browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
## These are Host-face tests
They type-check in the root `tsconfig.host.json`, not in the Client aggregate,
because they read Host services directly: `ctx.apiProxy`, the Host
`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does
not make a file part of the Client program — the two faces merge cordis
`Context` under the same keys with different services, so one program cannot see
both. Moving these files into the Client aggregate makes every Host-service
access fail to compile.
## Do not import `@deepseek-ai/dsh-client-*` here
Importing a Client package — a value or a type — pulls its whole TypeScript
project, and every project it references, into the **Host build graph**. That has
bitten this lane once already: four Client consumer packages reference
`api/remotes`' Client face, which cannot compile until Host tsdown has generated
`@deepseek-ai/dsh-goal/remote`, so the Host build phase ended up waiting on an
artifact it produces itself.
When a scenario needs a Client-owned constant or pure function, mirror it here
instead, next to the commented-out import that names the source module. A drift
then surfaces as a missed selector or an unsuppressed notice — a loud failure,
never a silent pass. `scaffold.ts` holds the mirrored welcome-notice values and
exports them for the scenarios that assert on them.
Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest
type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what
that harness is for, and both packages are already in the Host graph. Separately,
the chat scenarios import `conversationContextKey` from
`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable
through the unsplit `directory-picker` packages and pulls nothing further in.
That reachability is incidental, not a guarantee — if it ever leaves the graph,
mirror the helper like the rest.
Nothing mechanically enforces this rule; keep it in review.

View File

@@ -0,0 +1,37 @@
# apps/web 浏览器 e2e
[English](README.md) | 中文
这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane
的运行机制——模式、fixture、golden以及与 `dsh web` 之间刻意保留的组合差异——记录在
[`scaffold.ts`](scaffold.ts) 和
[浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)中。
## 这些是 Host 面的测试
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取
Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore``ctx.sessionProjectionCache`。运行时驱动
浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis
`Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处
Host 服务访问都无法编译。
## 不要在此 import `@deepseek-ai/dsh-client-*`
import 一个 Client 包——无论值还是类型——都会把它整个 TypeScript 工程、以及它引用的每个工程
拉进 **Host 构建图**。这已经坑过本 lane 一次:四个 Client 消费方包引用了 `api/remotes`
Client face而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 之后才能编译,
于是 Host 构建阶段变成在等一个由它自己产出的产物。
当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
import 点明源模块。这样漂移会表现为选择器未命中或提示未被抑制——是响亮的失败,绝不会是静默
通过。`scaffold.ts` 持有镜像的 welcome-notice 取值,并导出给断言它们的场景使用。
有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从
`@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该
harness 的用途,且这两个包本来就在 Host 图中。另外chat 场景从
`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为
`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是
偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。
没有任何机制强制这条规则;靠 review 守住它。

View File

@@ -20,13 +20,18 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
// The settings domain base: the only provider of ctx.settingsScope, which the
// locale and ui-theme rows below inject for their preference rows. Without it
// both stay pending and ui-layout never activates, so nothing renders.
{ id: '@deepseek-ai/dsh-client-ui-settings', bundlePath: 'packages/client/ui-settings/lib/client.js', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-api-remotes'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',

View File

@@ -21,7 +21,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The sidebar renders from the boot graph: every inject layer activated.
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The compact layout dropped group session counts; the fixture workspace
// group row renders immediately with its sessions beneath it.
const fixtureGroup = (await within(tree).findAllByText('fixture'))
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
.find(el => el?.getAttribute('aria-expanded') !== null)
if (fixtureGroup === undefined) throw new Error('fixture Workspace group missing')
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that

View File

@@ -12,14 +12,13 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import {
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const TURN_COUNT = 12

View File

@@ -11,7 +11,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
@@ -20,7 +19,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
import { conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SESSION_ID = 'chat-long-interactions-e2e'
@@ -78,8 +77,13 @@ async function nextPaint(page: Page): Promise<void> {
}
async function openSeed(page: Page): Promise<void> {
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// The compact layout dropped group session counts; the seeded baseline is
// the Ungrouped bucket once cold summaries load.
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })

View File

@@ -168,8 +168,10 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWor
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Session-list bootstrap can replace the controlled search state. Wait
// for the seeded baseline before openSeed starts the lazy content query.
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
// for the seeded baseline before openSeed starts the lazy content query
// (the compact layout dropped group session counts; the Ungrouped bucket
// row is the barrier).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
return {
events,
page,
@@ -258,7 +260,10 @@ async function conversationTurns(page: Page): Promise<number> {
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
// persisted first-message marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient

View File

@@ -24,7 +24,7 @@
//
// Only a real engine can show any of this. Scrolling is layout: jsdom reports
// `scrollHeight === clientHeight` for every element and never scrolls one, so
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can
// the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can
// only assert that one scrollport contains both layers.
//
// Zero model calls: a fresh workspace's blank session already carries a live

View File

@@ -249,7 +249,10 @@ async function compareTabsWithoutReservation(page: Page): Promise<TabComparison>
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000

View File

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

View File

@@ -29,7 +29,7 @@ const PACKAGE_FILES: Readonly<Record<string, string>> = {
'packages/client/ui-conversation/README.md': '# UI conversation\n',
'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
'packages/client/ui-conversation/src/client.ts': 'export {}\n',
'packages/client/ui-conversation/tests/chat-view.spec.tsx': 'export {}\n',
'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n',
'packages/context/session-reference/README.md': '# Session reference\n',
'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
'packages/context/session-reference/src/index.ts': 'export {}\n',

View File

@@ -1,4 +1,4 @@
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -92,14 +92,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
[process.execPath, binPath, 'web', '--port', '0'],
world,
{
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
DSH_HOME: join(world, '.dsh'),
},
))
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web')
browser = await chromium.launch()
const page = await browser.newPage()
const pageErrors: string[] = []

View File

@@ -4,7 +4,7 @@
// 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
// sessions.attachment route, the single-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'
@@ -66,10 +66,10 @@ it('renders the history image pair through the authorized attachment route and o
`)
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
// Double-click opens the original-size lightbox; Escape/close dismisses it.
// A single 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)
fireEvent.click(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/ }))
@@ -133,4 +133,65 @@ it('accepts pasted images into the composer rail in order and removes them', asy
await waitFor(() => {
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
})
// An unsupported file announces a transient toast (the inline strip is
// gone) and the banner dismisses itself after its hold-and-fade lifetime.
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }],
getData: () => '',
},
})
const toast = await screen.findByRole('alert')
expect(toast.textContent).toContain('Only PNG, JPG, WebP, and GIF images are supported')
await waitFor(() => {
expect(screen.queryByRole('alert')).toBeNull()
}, { timeout: 6_000 })
})
it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', 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)
const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
// A file drag anywhere over the page raises the full-viewport overlay whose
// desc line carries the projected limits — copy that can only render after
// the imageLimits projection crossed the real fixture transport.
const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
fireEvent.dragEnter(document.body, { dataTransfer })
const overlay = await screen.findByRole('status')
expect(overlay.textContent).toContain('Drag images here to add them')
await waitFor(() => {
expect(overlay.textContent).toContain('Up to 20 images, 5MB each')
})
// Dropping on the transcript area (not the composer card) lands in the rail.
fireEvent.drop(document.body, { dataTransfer })
await waitFor(() => {
const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
if (rail === null) throw new Error('attachment rail missing after page drop')
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png'])
}, { timeout: 5_000 })
expect(screen.queryByRole('status')).toBeNull()
// An intake that would exceed the projected per-message count is refused as
// a whole batch at add time: the banner names the limit and the rail keeps
// only the previously accepted thumbnail — no submit-time rollback.
const batch = Array.from({ length: 20 }, (_, i) =>
new File([new Uint8Array([137, 80, 78, 71])], `bulk-${String(i)}.png`, { type: 'image/png' }))
fireEvent.paste(textarea, {
clipboardData: {
items: batch.map(file => ({ kind: 'file', type: 'image/png', getAsFile: () => file })),
getData: () => '',
},
})
const banner = await screen.findByRole('alert')
expect(banner.textContent).toContain('A message can include up to 20 images')
const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
})

View File

@@ -197,8 +197,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
// Browser: the sidebar tree now carries the auto-created workspace group
// with its one session, and the opened session is the selected row.
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// with its one session, and the opened session is the selected row. The
// compact layout dropped group session counts, so the group row itself is
// the barrier.
await expect.poll(
() => page.locator('[role="treeitem"][aria-expanded]').filter({ hasText: 'workspace' }).count(),
{ timeout: 15_000 },
).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Host: the session's durable header cwd is the folder the workspace

View File

@@ -0,0 +1,121 @@
// Keyless browser regression for durable per-message feedback. Cold-seeds a
// settled two-turn transcript (zero model calls), rates one assistant message,
// attaches a note, proves both survive a full page reload from the Host's
// message-feedback sidecar, then retracts the rating.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold,
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
// Borrowed read-only: this scenario needs any settled assistant message to
// address, not a new recording (message-actions / sidebar-scrollbar pattern).
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'message-feedback-web-e2e'
const NOTE = 'Read both files before answering.'
describe('web e2e: durable per-message feedback', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
/**
* Open the seeded transcript. The first treeitem is the collapsible group
* row; the session itself is the row beneath it. The group is already
* expanded on a fresh load, so clicking it unconditionally would collapse it
* and hide the session row.
*/
async function openSeededSession(): Promise<void> {
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 15_000 })
await sessionRow.click()
}
it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback'))
await openSeededSession()
// The controls live in the assistant message's IconActions row, which the
// transcript reveals on hover/focus like copy and branch. Wait for the
// settled closing text first: the strip mounts with that turn's tail.
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
const like = page.getByRole('button', { name: 'Good response' }).first()
await like.waitFor({ timeout: 30_000 })
await like.scrollIntoViewIfNeeded()
await like.hover()
await like.click()
// A recorded rating relabels the button to what the next click would do,
// so the pressed control is addressed by the retract label from here on.
const rated = page.getByRole('button', { name: 'Remove rating' }).first()
await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true')
// A rated message offers the note editor; an unrated one does not.
await page.getByRole('button', { name: 'Add a note' }).first().click()
const editor = page.getByRole('textbox', { name: 'Feedback note' })
await editor.fill(NOTE)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect.poll(() => editor.count(), { timeout: 10_000 }).toBe(0)
await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
// The durable assertion: a cold browser re-reads the sidecar over the wire.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession()
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
// The controller defers its list read to the first hover or focus, so a
// cold reload shows the unrated label until the strip is touched. Hovering
// the unrated control is what triggers the authoritative re-read.
const cold = page.getByRole('button', { name: 'Good response' }).first()
await cold.waitFor({ timeout: 30_000 })
await cold.scrollIntoViewIfNeeded()
await cold.hover()
const restored = page.getByRole('button', { name: 'Remove rating' }).first()
await restored.waitFor({ timeout: 30_000 })
await restored.scrollIntoViewIfNeeded()
await restored.hover()
await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true')
await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
// Re-clicking the active rating retracts it, and the note goes with it.
await restored.click()
await expect.poll(
() => page.getByRole('button', { name: 'Good response' }).first().getAttribute('aria-pressed'),
{ timeout: 10_000 },
).toBe('false')
await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
}, 90_000)
it.skipIf(MODE === 'record')('kept the console clean', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -55,6 +55,10 @@ describe('minimal agent preset', () => {
const requestHeader = agentHandle.agent.session.requestHeader()
if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')
expect(presetFileSystem).toBeDefined()
expect(presetFileSystem?.sandboxMode).toBeUndefined()
expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compact')).toBeUndefined()
const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
await mkdir(stateDir)

View File

@@ -53,7 +53,10 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom
async function ensureSeedOpen(page: Page): Promise<void> {
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
@@ -119,8 +122,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
// the ready projection replaces.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
// the ready projection replaces (the compact layout dropped group session
// counts; the Ungrouped bucket row is the barrier).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterEach(async () => {
@@ -176,9 +180,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// The API baselines can settle before React commits their projection. The
// seeded count is the final user-visible barrier before editing search.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// seeded Ungrouped bucket row is the final user-visible barrier before
// editing search (the compact layout dropped group session counts).
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
await search.fill('zzzqx-no-such-session')

View File

@@ -10,14 +10,12 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_ACK_FIELD,
WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')

View File

@@ -0,0 +1,128 @@
// Keyless browser e2e: a user who configures some OTHER provider is not asked
// for the official DeepSeek key again, and the first-run setup card is a card
// they can close. The shipped DeepSeek adapter stays mounted without a
// credential throughout, so the only thing that ends onboarding here is the
// pi-ai route the user configures through the real wire. Zero model calls:
// configuration is pure settings/credentials/llm-domain traffic.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url))
const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md')
const MODE = webSnapshotMode()
const CREDENTIAL_STEP = '添加一个 API Key 开始使用'
describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
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('closes the setup card without discarding the add card beside it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP })
await credentialStep.waitFor({ timeout: 15_000 })
await credentialStep.getByRole('button', { name: '前往配置' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
// Nothing is reachable yet, so DeepSeek presents itself as its open card.
const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
await setupKey.waitFor({ timeout: 10_000 })
const add = settings.getByRole('button', { name: '添加提供方' })
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
const pick = settings.getByLabel('提供方')
await pick.waitFor({ timeout: 10_000 })
await pick.selectOption('minimax-cn')
await expect.poll(
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(2)
// Cancelling the setup card is the regression: it used to leave itself open
// and close the add card, discarding that draft.
await settings.getByRole('button', { name: '取消', exact: true }).first().click()
expect(await settings.getByLabel('提供方').count()).toBe(1)
await expect.poll(
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(1)
// DeepSeek is now an ordinary row: a missing-key dot and an Edit button.
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stops prompting for DeepSeek once the other provider can serve requests', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider'))
const settings = page.getByRole('dialog', { name: '设置' })
await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
await settings.getByRole('button', { name: '保存', exact: true }).click()
await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 })
// Only minimax-cn is reachable; DeepSeek still holds no credential.
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
expect(credentials).not.toContain('DEEPSEEK_API_KEY')
const warningsBefore = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
// The regression: the step read only the official route's credential, so a
// fully configured user was taken over on every blank session.
await expect.poll(
async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(),
{ timeout: 10_000 },
).toBe(0)
expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
// The Models page agrees: DeepSeek stays a row rather than reopening its
// setup card over a user who already has somewhere to send a request.
await page.getByRole('button', { name: '设置', exact: true }).click()
await settings.waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '模型' }).click()
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
expect((await page.content()).includes('sk-e2e-minimax')).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md'])
})
})

View File

@@ -9,3 +9,5 @@
- insert:
- id: directory-picker-browse
name: '@deepseek-ai/dsh-host-directory-picker-browse'
- id: ui-directory-picker
name: '@deepseek-ai/dsh-client-ui-directory-picker'

View File

@@ -0,0 +1,176 @@
// Web e2e scenario: the Plugins settings section — the cards a deployment's
// exposed host-plane namespaces produce, one field edited through the real
// wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset
// that layering produces. Zero model calls: everything is client state plus
// the settings document on a blank frame, so there is no fixture and a stray
// stream would fail loud on the open llm seam.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plugin-config', import.meta.url))
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: plugin configuration section', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
// Chinese browser: the section asserts the localized copy the client
// derives from it, as the rest of the settings surface does.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
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()
})
/**
* Open the settings dialog on the Plugins section. The scenarios share one
* page so the settings document accumulates across them, so this leaves any
* dialog a previous scenario opened closed first — its mask would otherwise
* swallow the trigger click.
*/
async function openPlugins() {
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
}
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '插件配置', exact: true }).click()
await expect
.poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
.toBe('true')
return dialog
}
/** The settings document as the Host has written it so far. */
async function settingsDocument(): Promise<string> {
return readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8').catch(() => '')
}
it('shows one card per exposed host-plane namespace', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards'))
const dialog = await openPlugins()
// Every card the shipped web composition exposes: the shell executor, the
// agent loop, and the DeepSeek search provider.
await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
// Collapsed: a card's fields appear only once it is expanded.
expect(await dialog.getByLabel('命令超时(毫秒)').count()).toBe(0)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stages an edit and writes it only when saved', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
// The composed default this deployment ships, before any user layer.
expect(await timeout.inputValue()).toBe('60000')
await timeout.fill('12000')
await timeout.blur()
// Nothing crosses the wire until the user saves: leaving the control is
// not a decision to store the value.
expect(await settingsDocument()).not.toContain('timeoutMs')
const save = dialog.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isEnabled(), { timeout: 5_000 }).toBe(true)
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
.toBe(true)
// Presence in the user layer is what the badge reports, and the reset is
// offered only for a field that has one.
await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
expect(await dialog.getByRole('button', { name: '恢复默认' }).count()).toBe(1)
// A settled form offers no save to repeat.
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('drops a staged edit on discard without touching the document', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-discard'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('7000')
await dialog.getByRole('button', { name: '放弃修改' }).click()
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('12000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('refuses to save a draft that is not a number', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-invalid'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('soon')
const save = dialog.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(await dialog.getByText('请填数字;留空表示使用默认值。').count()).toBe(1)
await dialog.getByRole('button', { name: '放弃修改' }).click()
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('clears the field back to the composed default on reset', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-reset'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
expect(await timeout.inputValue()).toBe('12000')
// The reset stages the composed default; the document still carries the
// override until the save lands.
await dialog.getByRole('button', { name: '恢复默认' }).click()
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
.toBe(false)
expect(await timeout.inputValue()).toBe('60000')
expect(await dialog.getByText('已覆盖').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['section.expected.md'])
})
})

View File

@@ -1,28 +1,107 @@
// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds
// a recorded write turn (zero model calls). Package tests cover the derivation
// in isolation, but only the assembled application shows that a turn's writes
// reach the transcript as an openable row (docs/testing.md snapshot rule). The
// click itself is not driven here: it hands the path to the Host's opener,
// which would launch a real application on the machine running the suite.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
// Web e2e scenario: the single-line produced-files summary a finished turn
// ends with. Cold-seeds ten writes (zero model calls), then verifies the real
// assembled lane keeps a precise +N and a capability-gated folder handoff.
// The folder request is intercepted so one real browser click can exercise
// the full client carrier without launching a native application in CI.
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 { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a
// file, not a new recording (the message-actions borrowing pattern).
const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url))
const SEED_ID = 'produced-files-web-e2e'
const DONE = 'PRODUCED_FILES_DONE'
/** The file the borrowed recording's write tool produces. */
const PRODUCED = 'policy-neutral.txt'
/** Short leading names plus a long third name make the narrow lane deterministically show two. */
const PRODUCED = [
'关于我.md',
'index.html',
'long-generated-experience-specification-for-produced-files-overflow.md',
'styles.css',
'app.ts',
'schema.json',
'README.md',
'preview.svg',
'notes.txt',
'manifest.yaml',
] as const
/** Build one settled turn whose successful write calls carry ten locations. */
function producedFixture(): string {
const session = Session.create(SessionId('produced-files-source'))
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Create the site files.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Produced files overflow', messageSeqs: [user.seq], source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
const calls = PRODUCED.map((path, index) => ({
path,
callId: CallId(`produced-files-${String(index)}`),
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
}))
session.append('assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: calls.map(call => ({
type: 'tool-call' as const,
id: call.callId,
name: 'write',
arguments: call.args,
})),
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
for (const call of calls) {
const source = session.append('tool/call', {
turn: 1, step: 1, callId: call.callId, name: 'write', arguments: call.args,
})
session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: call.callId,
content: [{ type: 'text', text: `Created ${call.path}` }],
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
turn: 1,
step: 2,
message: createAssistantMessage({
content: [{ type: 'text', text: `Created the site.\n\n${DONE}` }],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
createdAt: 0, cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify({
...event, time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
describe('web e2e: a finished turn ends with the files it produced', () => {
let scaffold: WebScaffold
@@ -31,16 +110,13 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The seeded Session's cwd is the scaffold workspace; the recording's own
// nested directory is created too, so its paths stay resolvable.
await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true })
await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n')
const raw = await readFile(SEED, 'utf8')
expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED)
await seedSession(scaffold, raw, SEED_ID)
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
await seedSession(scaffold, producedFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
// Keep the responsive sidebar available while selecting the cold seed;
// the assertion itself narrows the conversation after navigation.
await page.setViewportSize({ width: 1280, height: 900 })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -51,24 +127,52 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => {
it.skipIf(MODE === 'record')('keeps a narrow ten-file summary on one line with +8 and a folder action', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
// The row the turn ends with — derived from the write call's locations,
// not from whatever the closing message happened to say.
const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first()
await chip.waitFor({ timeout: 15_000 })
expect(await chip.innerText()).toBe(PRODUCED)
// The full path stays reachable for a reader who wants to copy it.
expect(await chip.getAttribute('title')).toContain(PRODUCED)
// A turn's produced files are labelled, not left as bare chips.
expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0)
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await page.setViewportSize({ width: 780, height: 900 })
const row = page.locator('[data-produced-files-row]')
await row.waitFor({ timeout: 15_000 })
const chips = row.getByRole('button')
await expect.poll(() => chips.count()).toBe(2)
expect(await chips.nth(0).innerText()).toBe('关于我.md')
expect(await chips.nth(1).innerText()).toBe('index.html')
expect(await row.getByText('+ 8 files', { exact: true }).count()).toBe(1)
const showFolder = page.getByRole('button', { name: 'Show in folder', exact: true })
expect(await showFolder.count()).toBe(1)
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath')
.mockImplementation(async (request, _signal) => ({
rpcId: request.rpcId,
result: { ok: true, value: { opened: true as const } },
}))
try {
const [response] = await Promise.all([
page.waitForResponse(response => new URL(response.url()).pathname === '/api/host.openPath'),
showFolder.click({ clickCount: 1 }),
])
expect(response.status()).toBe(200)
expect(openPath).toHaveBeenCalledTimes(1)
expect(openPath.mock.calls[0]![0].payload).toEqual({ path: `${scaffold.workspaceCwd}/.` })
} finally {
openPath.mockRestore()
}
const tops = await row.locator(':scope > *').evaluateAll(elements =>
elements.map(element => element.getBoundingClientRect().top))
expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)
const geometry = await row.evaluate(element => ({
clientWidth: element.clientWidth, scrollWidth: element.scrollWidth,
}))
expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])

View File

@@ -0,0 +1,6 @@
# The summary test asserts the native-folder action without launching it. Pin
# the capability so headless Linux CI and desktop developer hosts expose the
# same UI branch; platform opener behavior belongs to the Host unit tests.
- id: api-gateway
config:
nativeOpen: true

View File

@@ -68,8 +68,11 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
// Open the seeded session through content search: the sidebar groups
// sessions by workspace and its row order is world-dependent, while the
// search index covers the seeded log deterministically.
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// search index covers the seeded log deterministically. Search is a
// collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
await search.fill('Run a PowerShell command')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)

View File

@@ -5,10 +5,10 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
WELCOME_NOTICE_COPY,
type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
const MODE = webSnapshotMode()

View File

@@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => {
callId: CallId('web-url-probe'),
name: 'bash',
arguments: {
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
command: 'printf \'%s\\n\' "$DSH_WEB_URL"',
description: 'Print current Web runtime',
},
agent,
})
expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe(`${scaffold.baseUrl}\nproduction\n`)
.toBe(`${scaffold.baseUrl}\n`)
})
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {

View File

@@ -41,9 +41,19 @@ import {
loadOverlayPatches,
} from '@deepseek-ai/dsh-app-boot'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
// Client packages must not be imported here: these e2e type-check in the Host
// aggregate, so a Client import pulls that package's whole project — and every
// project it references — into the Host build graph. Mirrored from
// packages/client/ui-settings-general/src/onboarding-copy.ts; a drift makes the
// pre-acknowledgement stop suppressing the notice, which fails loudly.
// import {
// WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
// } from '@deepseek-ai/dsh-client-ui-settings-general'
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
export const WELCOME_NOTICE_VERSION = '2026-07-30.7'
export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type {
@@ -375,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// able to change a golden.
{
id: 'agent-presets',
config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
config: {
default: 'standard',
roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }],
includeUserRoot: false,
},
},
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
@@ -416,7 +430,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// (apps/web IS @deepseek-ai/dsh-frontend); only the URL line is silenced.
// Preserve the composed surface-context choice because a patch replaces
// the row's complete config.
{ id: 'web-runtime', config: { mode: 'production', printUrl: false, surfaceContext } },
{ id: 'web-runtime', config: { printUrl: false, surfaceContext } },
...options.remoteAuthority === undefined
? []
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
@@ -429,10 +443,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// host: patch `name` is an assertion, not an override, hence the
// disable+insert pair.
{ id: 'directory-picker', disabled: true },
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
{ insert: [
{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
{ id: 'ui-directory-picker', name: '@deepseek-ai/dsh-client-ui-directory-picker' },
] },
...options.agentPresets === undefined
? []
: [{ id: 'agent-presets', config: options.agentPresets }],
// Never the derived harness-home root: a developer's own presets must not
// be able to change a golden, whatever roots a scenario asks for.
: [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]

View File

@@ -0,0 +1,545 @@
/** Keyless assembled-Web evidence for conversational Schedule delivery. */
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 type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import {
ScheduleId,
createEveryScheduleRecord,
foldScheduleEvents,
resolveEveryOccurrence,
type EveryScheduleRecord,
} from '@deepseek-ai/dsh-tool-schedule'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, conversationContextKey, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/schedule-after', import.meta.url))
const AFTER_EXPECTED = join(SNAPSHOT_DIR, 'conversation.expected.md')
const AT_EXPECTED = join(SNAPSHOT_DIR, 'at-conversation.expected.md')
const EVERY_EXPECTED = join(SNAPSHOT_DIR, 'every-conversation.expected.md')
const AFTER_PROVIDER = 'schedule-after-web-test'
const AT_PROVIDER = 'schedule-at-web-test'
const EVERY_PROVIDER = 'schedule-every-web-test'
const MODEL = 'reply'
const AFTER_PROMPT = 'Check the deployment log'
const AFTER_REPLY = 'Reminder: Check the deployment log.'
const AT_BROWSER_ZONE = 'Asia/Shanghai'
const AT_USER_PROMPT = 'Remind me to review the release window in a few seconds in my local time.'
const AT_PROMPT = 'Review the release window'
const AT_READY = 'Ready for a browser-local reminder request.'
const AT_ACK = 'Scheduled in your browser time zone.'
const AT_REPLY = 'Reminder: Review the release window.'
const EVERY_PROMPTS = ['Check primary metrics', 'Check secondary metrics'] as const
const EVERY_REPLY = 'Reminders: Check primary metrics; Check secondary metrics.'
const EVERY_INTERVAL_SECONDS = 60 * 60
const EVERY_FIXTURE_AGE_MS = 90 * 60 * 1_000
/** Emit one complete assistant text response. */
function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
/** Deterministic model seam that turns one due reminder into ordinary assistant prose. */
class ReminderAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield * textResponse(AFTER_REPLY)
}
}
/** Deterministic model seam for one multi-record fixed-rate batch. */
class EveryReminderAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield * textResponse(EVERY_REPLY)
}
}
interface LocalAt {
readonly date: string
readonly time: string
readonly time_zone: string
}
/** Render one future epoch as exact local calendar fields in an explicit zone. */
function localAt(epoch: number, timeZone: string): LocalAt {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(epoch).map(part => [part.type, part.value])) as Record<string, string>
return {
date: `${parts['year']}-${parts['month']}-${parts['day']}`,
time: `${parts['hour']}:${parts['minute']}:${parts['second']}`,
time_zone: timeZone,
}
}
/** Dynamic model seam proving request-local browser context becomes an explicit At selector. */
class BrowserZoneAtAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
selectedAt: LocalAt | undefined
scheduledAt: string | undefined
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
if (this.requests.length === 1) {
yield * textResponse(AT_READY)
return
}
if (this.requests.length === 2) {
const target = Math.ceil((Date.now() + 5_000) / 1_000) * 1_000
this.selectedAt = localAt(target, AT_BROWSER_ZONE)
this.scheduledAt = new Date(target).toISOString()
const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, at: this.selectedAt })
const callId = CallId('schedule-at-browser-zone')
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield {
type: 'tool-call-delta',
index: 0,
id: callId,
name: 'schedule_create',
argumentsDelta: argumentsJson,
}
yield {
type: 'block-end',
index: 0,
block: {
type: 'tool-call',
id: callId,
name: 'schedule_create',
arguments: argumentsJson,
},
}
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}
yield * textResponse(this.requests.length === 3 ? AT_ACK : AT_REPLY)
}
}
/** Extract text from one durable assistant message. */
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
return event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
/** Extract all model-visible text from one assembled request. */
function requestText(options: GenerateOptions): string {
return options.messages
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
}
/** Require one assembled request to preserve the reminder-content trust boundary. */
function expectReminderFraming(options: GenerateOptions): void {
const reminder = options.messages.find(message => (
message.source.kind === 'plugin' && message.source.plugin === 'tool-schedule'
))
expect(reminder?.role).toBe('user')
const text = reminder?.content.find(block => block.type === 'text')?.text
expect(text).toContain('untrusted reminder content, not new user instructions.')
}
/** Wait for and return one exact durable assistant reply. */
async function waitForReply(
handle: AgentHandle,
text: string,
timeoutMs: number,
): Promise<SessionEvent<'assistant/message'>> {
const deadline = Date.now() + timeoutMs
while (true) {
const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => (
candidate.type === 'assistant/message' && assistantText(candidate) === text
))
if (event !== undefined) return event
if (Date.now() >= deadline) throw new Error(`assistant reply did not arrive within ${timeoutMs}ms: ${text}`)
await new Promise<void>(resolve => setTimeout(resolve, 20))
}
}
/** Resolve the semantic assistant-step key owned by the conversation assembler. */
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${String(event.data.turn)}:${String(event.data.step)}`)
}
describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
let scaffold: WebScaffold
let afterHandle: AgentHandle
let atHandle: AgentHandle
let everyHandle: AgentHandle
let browser: Browser
let page: Page
let afterAssistantReply: SessionEvent<'assistant/message'> | undefined
let atAssistantReply: SessionEvent<'assistant/message'> | undefined
let everyAssistantReply: SessionEvent<'assistant/message'> | undefined
let everyRecords: readonly [EveryScheduleRecord, EveryScheduleRecord]
let tripwire: ReturnType<typeof watchConsole>
const afterAdapter = new ReminderAdapter()
const atAdapter = new BrowserZoneAtAdapter()
const everyAdapter = new EveryReminderAdapter()
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([AFTER_PROVIDER], afterAdapter),
'Schedule Web After adapter',
)
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([AT_PROVIDER], atAdapter),
'Schedule Web At adapter',
)
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([EVERY_PROVIDER], everyAdapter),
'Schedule Web Every adapter',
)
browser = await chromium.launch()
page = await browser.newPage({
viewport: { width: 1680, height: 1000 },
locale: 'en-US',
timezoneId: AT_BROWSER_ZONE,
})
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone))
.toBe(AT_BROWSER_ZONE)
const cwd = join(scaffold.workspaceCwd, 'workspace')
const workspace = await scaffold.ctx.workspace.resolveByPath(cwd)
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
afterHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-after-web-e2e'),
meta: { cwd },
agentOptions: { provider: AFTER_PROVIDER, model: MODEL },
})
afterHandle.agent.session.append('session/title', {
title: 'Scheduled After follow-up',
messageSeqs: [],
source: { kind: 'user' },
})
await workspace.attachSession(afterHandle.agent.id)
const afterCreated = await scaffold.ctx.tools.execute({
signal: AbortSignal.timeout(10_000),
callId: CallId('schedule-after-create'),
name: 'schedule_create',
arguments: { prompt: AFTER_PROMPT, after_seconds: 1 },
agent: afterHandle.agent,
})
if (afterCreated.isError) {
throw new Error(`Schedule After create failed: ${JSON.stringify(afterCreated.value)}`)
}
expect(afterCreated.value).toMatchObject({
id: 'schedule-1',
kind: 'after',
prompt: AFTER_PROMPT,
afterSeconds: 1,
state: 'scheduled',
deliveryMode: 'session-local',
})
afterAssistantReply = await waitForReply(afterHandle, AFTER_REPLY, 15_000)
await afterHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(afterHandle.agent.session)).resolves.toBe(true)
everyHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-every-web-e2e'),
meta: { cwd },
agentOptions: { provider: EVERY_PROVIDER, model: MODEL },
})
everyHandle.agent.session.append('session/title', {
title: 'Fixed-rate reminder batch',
messageSeqs: [],
source: { kind: 'user' },
})
const seededAt = Date.now()
everyRecords = [
createEveryScheduleRecord(
ScheduleId('schedule-every-primary'),
EVERY_PROMPTS[0],
EVERY_INTERVAL_SECONDS,
seededAt - EVERY_FIXTURE_AGE_MS,
),
createEveryScheduleRecord(
ScheduleId('schedule-every-secondary'),
EVERY_PROMPTS[1],
EVERY_INTERVAL_SECONDS,
seededAt - EVERY_FIXTURE_AGE_MS,
),
]
for (const record of everyRecords) {
everyHandle.agent.session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: record,
})
}
await expect(scaffold.ctx.sessions.flush(everyHandle.agent.session)).resolves.toBe(true)
await workspace.attachSession(everyHandle.agent.id)
const everyListed = await scaffold.ctx.tools.execute({
signal: AbortSignal.timeout(10_000),
callId: CallId('schedule-every-list'),
name: 'schedule_list',
arguments: {},
agent: everyHandle.agent,
})
expect(everyListed.isError).toBe(false)
everyAssistantReply = await waitForReply(everyHandle, EVERY_REPLY, 15_000)
await everyHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(everyHandle.agent.session)).resolves.toBe(true)
atHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-at-web-e2e'),
meta: { cwd },
agentOptions: { provider: AT_PROVIDER, model: MODEL },
})
atHandle.agent.session.append('session/title', {
title: 'Explicit local-time reminder',
messageSeqs: [],
source: { kind: 'user' },
})
atHandle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'Prepare the reminder test session.' }],
source: { kind: 'plugin', plugin: 'schedule-web-e2e' },
}))
await atHandle.agent.whenIdle()
expect(atAdapter.requests).toHaveLength(1)
await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true)
await workspace.attachSession(atHandle.agent.id)
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const workspaceItem = page.locator('[role="treeitem"]').first()
await workspaceItem.waitFor({ timeout: 15_000 })
const expansionDeadline = Date.now() + 5_000
while (await workspaceItem.getAttribute('aria-expanded') !== 'true') {
if (Date.now() >= expansionDeadline) throw new Error('workspace item did not expand')
if (await workspaceItem.getAttribute('aria-expanded') !== 'true') {
await workspaceItem.click()
}
await new Promise<void>(resolve => setTimeout(resolve, 50))
}
const atSession = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
await atSession.waitFor({ timeout: 15_000 })
await atSession.click()
const composer = page.locator('textarea:enabled').last()
await composer.fill(AT_USER_PROMPT)
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
expect(await settled).toBe(atHandle.agent.id)
await page.getByText(AT_ACK, { exact: true }).waitFor({ timeout: 15_000 })
atAssistantReply = await waitForReply(atHandle, AT_REPLY, 20_000)
await atHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true)
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await atHandle?.dispose().catch((error: unknown) => failures.push(error))
await everyHandle?.dispose().catch((error: unknown) => failures.push(error))
await afterHandle?.dispose().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'Schedule Web evidence teardown failed')
})
it('renders After as an ordinary assistant follow-up', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after'))
const reminderRequest = afterAdapter.requests[0]
if (reminderRequest === undefined) throw new Error('model did not receive the After reminder')
expectReminderFraming(reminderRequest)
const session = page.getByRole('treeitem', { name: /Scheduled After follow-up/ })
await session.click()
if (afterAssistantReply === undefined) throw new Error('After assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(afterAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(AFTER_REPLY)
await compareOrRefreshGolden(
AFTER_EXPECTED,
await captureStableAria(page, selector, scaffold.workspaceCwd),
MODE,
)
expect(await page.locator('[data-schedule-reminder]').count()).toBe(0)
}, 60_000)
it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every'))
const ids = new Set(everyRecords.map(record => record.id))
const dispatches = everyHandle.agent.session.events.filter(event => (
event.type === 'schedule/change'
&& event.data.operation === 'dispatch'
&& ids.has(event.data.id)
))
expect(dispatches).toHaveLength(2)
const acceptedAt = dispatches.map((event) => {
if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch'
|| !('acceptedAt' in event.data)) throw new Error('expected Every dispatch')
return event.data.acceptedAt
})
expect(new Set(acceptedAt).size).toBe(1)
const decision = acceptedAt[0]
if (decision === undefined) throw new Error('missing Every decision time')
const batch = everyHandle.agent.session.events.find(event => (
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tool-schedule'
&& event.data.content.some(block => block.type === 'text'
&& block.text.startsWith('[SCHEDULE REMINDER BATCH]'))
))
if (batch?.type !== 'user/message') throw new Error('missing Every batch message')
const batchBlock = batch.data.content.find(block => block.type === 'text')
if (batchBlock?.type !== 'text') throw new Error('missing Every batch text')
for (const record of everyRecords) {
const occurrenceAt = resolveEveryOccurrence(record, Date.parse(decision)).occurrenceAt
expect(batchBlock.text).toContain(JSON.stringify({
schedule_id: record.id,
occurrence_at: occurrenceAt,
reminder_prompt: record.prompt,
}).slice(1, -1))
}
expect(everyAdapter.requests).toHaveLength(1)
const reminderRequest = everyAdapter.requests[0]
if (reminderRequest === undefined) throw new Error('model did not receive the Every batch')
expect(requestText(reminderRequest)).toContain(batchBlock.text)
expectReminderFraming(reminderRequest)
const active = foldScheduleEvents(everyHandle.agent.session.events).active
expect(active).toHaveLength(2)
expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true)
const session = page.getByRole('treeitem', { name: /Fixed-rate reminder batch/ })
await session.click()
if (everyAssistantReply === undefined) throw new Error('Every assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(everyAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(EVERY_REPLY)
await compareOrRefreshGolden(
EVERY_EXPECTED,
await captureStableAria(page, selector, scaffold.workspaceCwd),
MODE,
)
expect(await page.locator('[data-schedule-reminder]').count()).toBe(0)
}, 60_000)
it('uses request-local browser context to create an explicit local At reminder', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at'))
const user = atHandle.agent.session.events.find(event => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT)
))
if (user?.type !== 'user/message' || user.data.source.kind !== 'user') {
throw new Error('missing browser user-rpc message')
}
expect(user.data.source).toMatchObject({ kind: 'user', clientTimeZone: AT_BROWSER_ZONE })
expect(typeof (user.data.source as { rpcId?: unknown }).rpcId).toBe('string')
const firstRequest = atAdapter.requests[1]
if (firstRequest === undefined) throw new Error('model did not receive the browser prompt')
expect(requestText(firstRequest)).toContain(
`Browser time zone for this request: ${AT_BROWSER_ZONE}. `
+ 'Interpret otherwise-unqualified dates and times in this zone.',
)
expect(firstRequest.tools?.some(tool => tool.name === 'schedule_create')).toBe(true)
const selectedAt = atAdapter.selectedAt
const scheduledAt = atAdapter.scheduledAt
if (selectedAt === undefined || scheduledAt === undefined) {
throw new Error('model did not choose an explicit local At target')
}
expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE)
const toolCall = atHandle.agent.session.events.find(event => (
event.type === 'tool/call' && event.data.name === 'schedule_create'
))
if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call')
expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt })
const created = atHandle.agent.session.events.find(event => (
event.type === 'schedule/change'
&& event.data.operation === 'create'
&& event.data.schedule.kind === 'at'
))
if (created?.type !== 'schedule/change' || created.data.operation !== 'create') {
throw new Error('explicit local At call did not create a durable record')
}
const schedule = created.data.schedule
expect(schedule).toMatchObject({
kind: 'at',
prompt: AT_PROMPT,
scheduledAt,
})
expect(atHandle.agent.session.events.filter(event => (
event.type === 'schedule/change'
&& event.data.operation === 'dispatch'
&& event.data.id === schedule.id
))).toHaveLength(1)
expect(atAdapter.requests).toHaveLength(4)
const reminderRequest = atAdapter.requests[3]
if (reminderRequest === undefined) throw new Error('model did not receive the At reminder')
expectReminderFraming(reminderRequest)
const session = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
await session.click()
if (atAssistantReply === undefined) throw new Error('At assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(atAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(AT_REPLY)
await compareOrRefreshGolden(
AT_EXPECTED,
await captureStableAria(page, selector, scaffold.workspaceCwd),
MODE,
)
expect(await page.locator('[data-schedule-reminder]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'at-conversation.expected.md',
'conversation.expected.md',
'every-conversation.expected.md',
])
})
})

View File

@@ -23,6 +23,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md')
const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]'
const MODE = webSnapshotMode()
describe('web e2e: settings modal and General preferences', () => {
@@ -92,6 +94,28 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: '模型' }).click()
await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
// Plugins is a read-only projection of the same assembled Loader tree.
// Capture one stable shipped row rather than the whole inventory so adding
// an unrelated plugin does not rewrite this surface's golden.
await dialog.getByRole('button', { name: '插件', exact: true }).click()
await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
await pluginRow.waitFor({ timeout: 10_000 })
const expectedPluginCount = [...scaffold.ctx.loader.entries()]
.filter(entry => !entry.options.group)
.length
expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1)
expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount)
expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
.toBe(String(expectedPluginCount))
expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true')
expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
const pluginsSnapshot = await captureStableAria(
page,
PLUGIN_ROW_SELECTOR,
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE)
// Close path 1: Escape.
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
@@ -152,6 +176,67 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('uses the persisted dark preference while plugins are still loading', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme'))
await page.emulateMedia({ colorScheme: 'light' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const initialDialog = page.getByRole('dialog', { name: '设置' })
const darkCube = initialDialog.getByRole('button', { name: '深色' })
await darkCube.click()
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-theme:\n\s+preference: dark/)
await page.keyboard.press('Escape')
// Hold real plugin bundles so the shell-owned loading page remains observable.
const pluginPattern = '**/plugins/**'
let releaseBundles = (): void => {}
const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
await page.route(pluginPattern, async (route) => {
await bundlesReleased
await route.continue()
})
const warningStart = tripwire.warnings.length
let reload: ReturnType<Page['reload']> | undefined
try {
reload = page.reload({ waitUntil: 'domcontentloaded' })
const loading = page.getByText('Loading plugins…', { exact: true })
await loading.waitFor({ timeout: 10_000 })
const state = await loading.evaluate((element) => {
const boot = element.parentElement?.parentElement
if (boot === undefined || boot === null) throw new Error('loading hint is detached from the boot page')
return {
attr: document.body.hasAttribute('data-ds-dark-theme'),
background: getComputedStyle(boot).backgroundColor,
colorScheme: document.documentElement.style.colorScheme,
}
})
expect(state).toEqual({
attr: true,
background: 'rgb(21, 21, 23)',
colorScheme: 'dark',
})
} finally {
releaseBundles()
await reload
await page.unroute(pluginPattern)
}
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '设置', exact: true }).click()
const restoredDialog = page.getByRole('dialog', { name: '设置' })
const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), {
timeout: 5_000,
}).toBe(false)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
interface ThemeState {
@@ -393,6 +478,6 @@ describe('web e2e: settings modal and General preferences', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md'])
})
})

View File

@@ -349,9 +349,9 @@ async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
* clicking once — startup auto-selection can expand the bucket first, and a
* second click would collapse it again. Hand-rolled polling because
* in the collapsed Ungrouped bucket. Open the bucket, then use its transient
* Show-more control because an open group intentionally renders only five
* rows by default. Hand-rolled polling because
* `expect.poll` is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
@@ -364,6 +364,12 @@ async function expandSeededSessions(page: Page): Promise<void> {
if (await bucket.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
}
const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
if (await bucket.getAttribute('aria-expanded') === 'true'
&& await rows.count() <= SEED_COUNT / 2
&& await showMore.count() > 0) {
await showMore.click()
}
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
if (Date.now() > deadline) {
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)

View File

@@ -27,7 +27,7 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
@@ -187,7 +187,7 @@ describe('dsh web keyless CLI smoke', () => {
}
})
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
it('routes web runtime context and workspace instructions through the real CLI request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
@@ -226,7 +226,7 @@ describe('dsh web keyless CLI smoke', () => {
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
{
cwd: workspace,
env: {
@@ -261,7 +261,7 @@ describe('dsh web keyless CLI smoke', () => {
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
.replace('{{webUrl}}', baseUrl)
expect(systemMessage?.content).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(`

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -33,6 +33,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -48,6 +48,10 @@
- paragraph: CORDIS_UI_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -20,6 +20,10 @@
- paragraph: LIGHTHOUSE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -2,6 +2,6 @@ You are an AI agent powered by the DeepSeek Harness SDK.
The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

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

View File

@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- group "Command input": /goal 做两个turn每个turn输出随机一个包的文件结构。注意你做完一个turn之后直接输出内容停止我们的系统会帮你再开一个turn你看着做一个类似的
- 'button "goal Goal created Status: active Objective: 做两个turn每个turn输出随机一个包的文件结构。注意你做完一个turn之后直接输出内容停止我们的系统会帮你再开一个turn你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
- img
- img
@@ -77,6 +78,10 @@
- paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
@@ -186,6 +191,10 @@
- text: )的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- tooltip "Branch into a new conversation"

View File

@@ -5,17 +5,17 @@
- img
- text: New Session
- text: Workspaces
- button "Group by":
- button "Search sessions":
- img
- textbox "Search sessions..."
- button "View options":
- img
- button "Add workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "New Session" [selected]
- button "Settings":
- img

View File

@@ -5,17 +5,17 @@
- img
- text: New Session
- text: Workspaces
- button "Group by":
- button "Search sessions":
- img
- textbox "Search sessions..."
- button "View options":
- img
- button "Add workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "New Session" [selected]
- button "Settings":
- img

View File

@@ -20,6 +20,10 @@
- paragraph: LIGHTHOUSE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -22,6 +22,10 @@
- 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.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -35,6 +35,10 @@
- paragraph: CJK_STRONG_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -14,6 +14,10 @@
- paragraph: REMOTE_IMAGE_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -26,6 +26,10 @@
- paragraph: INLINE_CODE_LINK_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -30,6 +30,10 @@
- paragraph: MATH_RENDERING_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -1,7 +1,7 @@
- tree "Sessions":
- treeitem "Ungrouped 3 sessions" [expanded]:
- treeitem "Ungrouped" [expanded]:
- img
- text: Ungrouped 3 sessions
- treeitem "Use the read tool twice (2) now" [selected]
- treeitem "Use the read tool twice (1) now"
- text: Ungrouped
- treeitem "Use the read tool twice 1min"
- treeitem "Use the read tool twice (1) now"
- treeitem "Use the read tool twice (2) now" [selected]

View File

@@ -15,6 +15,10 @@
- paragraph: I will read both files before answering.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
@@ -38,6 +42,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}}

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -0,0 +1,74 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- img "API 密钥缺失"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- text: 提供方
- combobox "提供方":
- option "amazon-bedrock"
- option "ant-ling"
- option "anthropic"
- option "azure-openai-responses"
- option "cerebras"
- option "cloudflare-ai-gateway"
- option "cloudflare-workers-ai"
- option "deepseek"
- option "fireworks"
- option "github-copilot"
- option "google"
- option "google-vertex"
- option "groq"
- option "huggingface"
- option "kimi-coding"
- option "minimax"
- option "minimax-cn" [selected]
- option "mistral"
- option "moonshotai"
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
- option "qwen-token-plan"
- option "qwen-token-plan-cn"
- option "together"
- option "vercel-ai-gateway"
- option "xai"
- option "xiaomi"
- option "xiaomi-token-plan-ams"
- option "xiaomi-token-plan-cn"
- option "xiaomi-token-plan-sgp"
- option "zai"
- option "zai-coding-cn"
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group: 自定义设置
- button "取消"
- button "保存"

View File

@@ -33,6 +33,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -0,0 +1,37 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "插件配置" [level=2]
- paragraph: 配置本部署已安装的插件。
- list:
- listitem:
- 'button "展开设置: 终端"':
- text: 终端 限制 agent 运行的每一条命令。
- img
- listitem:
- 'button "展开设置: Agent 循环"':
- text: Agent 循环 Agent 如何派发工具调用。
- img
- listitem:
- 'button "展开设置: 网页搜索"':
- text: 网页搜索 DeepSeek 搜索提供方。
- img

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

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

View File

@@ -0,0 +1 @@
- paragraph: "Reminder: Review the release window."

View File

@@ -0,0 +1 @@
- paragraph: "Reminder: Check the deployment log."

View File

@@ -0,0 +1 @@
- paragraph: "Reminders: Check primary metrics; Check secondary metrics."

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -7,9 +7,15 @@
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -0,0 +1,6 @@
- listitem:
- button "ui-settings, 已挂载, 已启用":
- strong: ui-settings
- img "已挂载"
- text: 已启用
- img

View File

@@ -1,6 +1,6 @@
- tree "Sessions":
- treeitem "workspace 2 sessions" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 2 sessions
- treeitem "1 subagent running Delegate a background task. now"
- text: workspace
- treeitem "New Session" [selected]
- treeitem "1 subagent running Delegate a background task. now"

View File

@@ -31,6 +31,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -20,6 +20,10 @@
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -13,7 +13,6 @@
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- text: Running
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
- img
- img

View File

@@ -30,6 +30,10 @@
- paragraph: "Got it: BANANA and ORANGE."
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -31,6 +31,10 @@
- paragraph: Great, let's move forward. BANANA!
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -1,6 +1,6 @@
- tree "Sessions":
- treeitem "workspace 2 sessions" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 2 sessions
- treeitem "Explain event sourcing in one (1) now" [selected]
- text: workspace
- treeitem "Ask a research subagent to now"
- treeitem "Explain event sourcing in one (1) now" [selected]

View File

@@ -1,5 +1,5 @@
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- treeitem "workspace" [expanded]:
- img
- text: workspace 1 session
- text: workspace
- treeitem "Ask a research subagent to now"

View File

@@ -25,6 +25,10 @@
- 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.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}}
@@ -37,6 +41,10 @@
- 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.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -1 +0,0 @@
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.

View File

@@ -0,0 +1 @@
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.

View File

@@ -20,6 +20,10 @@
- paragraph: SEARCH_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -0,0 +1,36 @@
- text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}"
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:":
- img
- img
- text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:"
- button "Tool call workflow ·":
- img
- img
- text: Tool call workflow ·
- button "snapshot-flow 1 member Completed" [expanded]:
- img
- text: snapshot-flow 1 member Completed
- button "Run 1 member Completed 1" [expanded]:
- img
- text: Run 1 member Completed 1
- text: Reply with exactly the word WF_CHILD_OK and not… Completed
- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.":
- img
- img
- text: Think The workflow returned successfully with the reply "WF_CHILD_OK". Now I need to reply with exactly "WORKFLOW_DONE" and stop.
- paragraph: WORKFLOW_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -68,6 +68,13 @@ describe('web e2e: startup auto-selection', () => {
it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
const headline = page.getByText('Into the Unknown', { exact: true })
const fish = headline.locator('xpath=preceding-sibling::span[1]/*[name()="svg"]')
const fishHitbox = fish.locator('..')
expect(await fish.evaluate(node => getComputedStyle(node).color))
.toBe(await headline.evaluate(node => getComputedStyle(node).color))
await fishHitbox.hover()
expect(await fish.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none')
await page.evaluate(() => {
const refs = {
root: document.querySelector('div[data-phase="hero"]'),

View File

@@ -354,10 +354,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
{ timeout: 10_000 },
).toBe(2)
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
// The reasoning row streams independently of the steering handoff; wait
// for it so the mid snapshot pins the assistant step, not the pre-render
// gap a fast machine can catch between steering acceptance and the block.
await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 })
// The reasoning row streams independently of the steering handoff. Wait
// for the block to settle so the mid snapshot does not race its transient
// visually-hidden Running label while the question keeps the turn open.
await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ timeout: 10_000 })
const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)

View File

@@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer'
const NESTED_LABEL = 'example editor'
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
/** The grandchild's own first message; its arrival is what says its history finished loading. */
const NESTED_PROMPT = 'Give one concrete event sourcing example.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
@@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
seq: 1,
time: authoredAt + 1,
data: {
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
content: [{ type: 'text', text: NESTED_PROMPT }],
source: { kind: 'user' },
},
surfaceOp: 'append',
@@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
)
await nestedRow.click()
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
// The offline banner renders from the descriptor alone, so it says nothing
// about the transcript below it. The golden pins that transcript, and
// `captureStableAria` calls two identical polls stable — including two of
// "Loading history…". Wait for the message the golden asserts.
await page.getByText(NESTED_PROMPT).waitFor()
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
const crumbs = await hierarchy.getByRole('button').allTextContents()
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])

View File

@@ -119,3 +119,17 @@ export async function saveFailureShot(page: Page, name: string): Promise<void> {
// Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error.
}
}
/**
* The conversation engine's Context key format, restated here rather than
* imported: these specs live in the Host compiler aggregate, which must not
* reach the Client plane. The engine's own copy is
* `conversationContextKey` in dsh-client-runtime; a drift between them makes
* the key miss its rendered node, so the assertion fails loudly.
* @param kind - Definition kind.
* @param id - Definition-local business identity.
* @returns the engine-owned Context key.
*/
export function conversationContextKey(kind: string, id: string): string {
return `${kind.length}:${kind}${id}`
}

View File

@@ -59,7 +59,10 @@ interface RowAnchor {
}
async function openSeed(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
@@ -182,7 +185,9 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
// The compact layout dropped group session counts; the seeded baseline is
// the Ungrouped bucket once cold summaries load.
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterAll(async () => {

View File

@@ -0,0 +1,191 @@
// Keyless shipped-Web acceptance for the durable workflow Conversation Node.
// Reuses the existing recorded workflow parent/child model fixtures; the real
// workflow tool, worker, subagent provider, Session log, browser plugin graph,
// and navigation all execute during replay.
import { readFile } 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 type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import {
connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot,
} from './support.ts'
const MODE = webSnapshotMode()
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl')
const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl')
const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.'
describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let prompt: string
const waitForParentSettlement = (): Promise<SessionId> => new Promise((resolve, reject) => {
let dispose = (): void => {}
dispose = scaffold.ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'turn/end' || session.header.origin === 'subagent') return
dispose()
void (async () => {
await scaffold.ctx.agents.get(session.id)?.whenIdle()
await scaffold.ctx.sessions.flush(session)
resolve(session.id)
})().catch(reject)
})
})
beforeAll(async () => {
const prompts = fixtureUserPrompts(await readFile(PARENT_FIXTURE, 'utf8'))
expect(prompts).toHaveLength(1)
prompt = prompts[0]!
scaffold = await launchWebScaffold({
replayFixture: PARENT_FIXTURE,
replayChildFixtures: [CHILD_FIXTURE],
paceMs: 25,
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live'))
const settled = waitForParentSettlement()
const input = page.locator('textarea').first()
await input.fill(prompt)
await input.press('Enter')
const workflow = page.locator('[data-workflow-run][data-run-status="running"]')
await workflow.waitFor({ timeout: 30_000 })
const disclosures = workflow.locator('[data-disclosure-row]')
await disclosures.nth(1).waitFor({ timeout: 15_000 })
expect(await disclosures.nth(0).getAttribute('role')).toBeNull()
expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull()
expect(await disclosures.nth(1).getAttribute('role')).toBeNull()
expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull()
expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
await member.waitFor({ timeout: 15_000 })
await member.focus()
const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color)
await page.setViewportSize({ width: 560, height: 800 })
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkNarrow = await page.locator('[data-workflow-run]').evaluate((element) => {
const panel = element as HTMLElement
panel.style.width = '356px'
const label = element.querySelector('[data-member-label]')
const labelWrap = element.querySelector('[data-member-label-wrap]')
const status = element.querySelector('[data-member-status-text]')
const disclosures = element.querySelectorAll('[data-disclosure-row]')
const runHeader = disclosures[0]
const phaseHeader = disclosures[1]
const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null
const phaseStatus = element.querySelector('[data-phase-status-text]')
const originalPhaseTitle = phaseTitle?.textContent ?? ''
if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout'
const phaseTitleRight = phaseTitle?.getBoundingClientRect().right ?? 0
const phaseStatusLeft = phaseStatus?.getBoundingClientRect().left ?? 0
if (phaseTitle !== null) phaseTitle.textContent = originalPhaseTitle
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
color: label === null ? '' : getComputedStyle(label).color,
decoration: label === null ? '' : getComputedStyle(label).textDecorationLine,
focusWidth: labelWrap === null ? '' : getComputedStyle(labelWrap).outlineWidth,
statusWidth: status?.getBoundingClientRect().width ?? 0,
statusFontSize: status === null ? '' : getComputedStyle(status).fontSize,
runHeight: runHeader?.getBoundingClientRect().height ?? 0,
phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0,
phaseTitleRight,
phaseStatusLeft,
}
})
expect(darkNarrow.clientWidth).toBe(356)
expect(darkNarrow.scrollWidth).toBeLessThanOrEqual(darkNarrow.clientWidth)
expect(darkNarrow.color).not.toBe(lightColor)
expect(darkNarrow.decoration).toContain('underline')
expect(Number.parseFloat(darkNarrow.focusWidth)).toBeGreaterThanOrEqual(2)
expect(darkNarrow.statusWidth).toBe(64)
expect(darkNarrow.statusFontSize).toBe('13px')
expect(darkNarrow.runHeight).toBe(32)
expect(darkNarrow.phaseHeight).toBe(32)
expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft)
await page.locator('[data-workflow-run]').evaluate((element) => {
(element as HTMLElement).style.removeProperty('width')
document.body.removeAttribute('data-ds-dark-theme')
})
await page.setViewportSize({ width: 1280, height: 800 })
await member.click()
await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 })
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
await settled
await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
await terminalWorkflow.waitFor()
expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false')
expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
await terminalWorkflow.click()
const terminalPhase = page.getByRole('button', { name: /^Run/ })
await terminalPhase.waitFor()
expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false')
expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
await terminalPhase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
await expect.poll(
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
{ timeout: 10_000 },
).toBe(0)
}, 90_000)
it('rebuilds the terminal record from history after reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history'))
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
await workflow.waitFor({ timeout: 15_000 })
expect(await workflow.getAttribute('aria-expanded')).toBe('false')
await workflow.click()
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor()
expect(await phase.getAttribute('aria-expanded')).toBe('false')
await phase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}, 60_000)
it('stays clean and owns only its one golden', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -372,21 +372,22 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
// Grouped default: workspace group rows render (the seeded session sits
// under Ungrouped; the created workspaces are empty groups).
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await page.getByRole('button', { name: 'Group by' }).click()
// Grouping and ordering moved into the View options menu.
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'In one list' }).click()
// Flat mode: the section label flips and the seeded session is a
// top-level row with no group headers above it.
await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat')
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view.v4'))).toContain('flat')
// Persisted across reload; then restore grouped for inter-spec hygiene.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
await page.getByRole('button', { name: 'Group by' }).click()
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
expect(tripwire.pageErrors).toEqual([])

View File

@@ -36,11 +36,13 @@
"tests/trajectory-virtualization.e2e.ts",
"tests/lifecycle-chrome.e2e.ts",
"tests/details-session-lifecycle.e2e.ts",
"tests/plugin-config.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/default-model.e2e.ts",
"tests/declared-reasoning.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/onboarding-usable-provider.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
@@ -53,6 +55,7 @@
"tests/cordis-tool-round.e2e.ts",
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/message-feedback.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/markdown-cjk-strong.e2e.ts",
@@ -65,11 +68,13 @@
"tests/agent-preset-selection.e2e.ts",
"tests/agent-preset-authoring.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/schedule-after.e2e.ts",
"tests/feedback-command.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
"tests/produced-file-mentions.e2e.ts",
"tests/goal-bar.e2e.ts",
"tests/goal-command-presentation.e2e.ts",
"tests/subagent-conversation.e2e.ts",
"tests/subagent-interrupt.e2e.ts",
"tests/subagent-interrupt-ui.e2e.ts",
@@ -85,7 +90,8 @@
"tests/chat-continuous-conversation.e2e.ts",
"tests/composer-tab-geometry.e2e.ts",
"tests/complex-history.perf.ts",
"tests/pwsh-terminal.e2e.ts"
"tests/pwsh-terminal.e2e.ts",
"tests/workflow-run.e2e.ts"
],
"references": [
{

View File

@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
+ 'For client-plugin HMR, run `pnpm dsh web --dev` together with `pnpm run dev:web`.'
+ 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
function rejectStandaloneServe(): Plugin {
@@ -143,6 +143,7 @@ export default defineConfig({
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-attachment$/, replacement: src('../../packages/client/ui-attachment/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
],