Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e

# Conflicts:
#	docs/subsystems/tasks.i18n.yaml
#	docs/subsystems/tasks.md
#	docs/subsystems/tasks.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/tasks/tasks-local/src/index.ts
#	packages/tasks/tasks/README.i18n.yaml
#	packages/tasks/tasks/README.md
#	packages/tasks/tasks/README.zh.md
#	packages/tasks/tasks/src/index.ts
This commit is contained in:
Yichen Jiang
2026-08-11 11:57:33 +08:00
2697 changed files with 39978 additions and 18500 deletions

View File

@@ -1,8 +1,15 @@
{
"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",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "apps/web"
},
"type": "module",
"exports": {
"./dist/*": "./dist/*",
@@ -23,11 +30,12 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@cordisjs/plugin-group": "workspace:^",
"@deepseek-ai/cordis-plugin-group": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",

View File

@@ -44,9 +44,15 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
return page.getByRole('dialog', { name: '设置' })
}
/** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */
/** Tokenize the lane-owned preset root after general aria normalization. */
function withPresetRoot(snapshot: string): string {
return snapshot.split(userRoot).join('{{presetRoot}}')
const rootSuffix = `/${userRoot.split('/').pop()!}`
return snapshot.split('\n').map((line) => {
const rootStart = line.indexOf(rootSuffix)
if (rootStart === -1) return line
const pathStart = line.lastIndexOf(' ', rootStart) + 1
return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
}).join('\n')
}
beforeAll(async () => {
@@ -155,7 +161,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
expect(metadata).toContain('name: 我的模式')
expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。')
expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。')
expect(metadata).not.toContain('order:')
}, 60_000)
@@ -170,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
// Custom group gone with its only member; the shipped set stands.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
// The custom group outlives its only member: the heading stays with the
// creator entry so the place to author a preset never disappears.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)

View File

@@ -11,10 +11,15 @@
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { fileURLToPath } from 'node:url'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
} from '@deepseek-ai/dsh-session'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
@@ -29,6 +34,30 @@ const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'agent-preset-selection-web-e2e'
/** A project skill only a preset that mounts `skill-local` can discover. */
const SKILL_NAME = 'preset-catalog-demo'
/**
* Seed one project skill under the connected workspace.
*
* Local skill discovery is a PRESET row, so this file is visible through
* `standard` and invisible through `minimal` — which makes the '/' menu's
* skill group a statement about the session's composition.
* @param workspaceCwd - the scaffold's temp project parent.
*/
async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> {
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), [
'---',
`name: ${SKILL_NAME}`,
'description: Prove the slash catalog follows the session composition',
'---',
'',
'Body.',
'',
].join('\n'))
}
/**
* A settled one-turn session with no model content: this lane asserts chrome
@@ -53,6 +82,89 @@ function seedLog(): string {
].join('\n')
}
/**
* Persist one child so the assembled header snapshot exercises both action
* contributors whose relative order is the product contract under test.
* @param scaffold - the booted Web scaffold.
* @param parentId - the seeded session whose header the browser opens.
*/
async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise<void> {
const childId = sessionId('agent-preset-selection-child')
const createdAt = 1784974100100
await scaffold.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: childId,
createdAt,
cwd: scaffold.workspaceCwd,
parentSession: parentId,
origin: 'subagent',
delegationDepth: 1,
agentPreset: 'minimal',
})
await scaffold.ctx.sessionPersistence.append(childId, [
{
type: 'turn/start',
seq: 0,
time: createdAt,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'user/message',
seq: 1,
time: createdAt + 1,
data: {
content: [{ type: 'text', text: 'Check the session-header action order.' }],
source: { kind: 'user' },
},
surfaceOp: 'append',
},
{
type: 'subagent/descriptor',
seq: 2,
time: createdAt + 2,
data: snapshotSubagentDescriptor({
mode: 'one-shot', provider: 'spawn', label: 'header order probe',
}),
},
{
type: 'turn/end',
seq: 3,
time: createdAt + 3,
data: { turn: 1, reason: { kind: 'completed' } },
},
] as SessionEvent[])
await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId)
}
/**
* The preset the host reports for the blank session the workspace connect
* produced. Addressed by id rather than by scanning the serialized list: the
* seeded session records `minimal` too, so a substring match over the whole
* list answers before the switch has landed.
* @param baseUrl - the scaffold's origin.
* @returns the live session's preset, or undefined before it is listed.
*/
async function livePreset(baseUrl: string): Promise<string | undefined> {
const response = await fetch(`${baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
}
return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
}
/** Every option label the trigger menu currently lists. */
async function menuOptions(page: Page): Promise<string[]> {
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
return await menu.getByRole('option').allTextContents()
}
describe('web e2e: agent-preset selection', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -66,7 +178,9 @@ describe('web e2e: agent-preset selection', () => {
// A resumed session runs what it was created with; seeding one that
// records `minimal` is what makes the header label a claim about the
// session rather than an echo of the current default.
await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
const seededId = await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
await seedSubagent(scaffold, seededId)
await seedWorkspaceSkill(scaffold.workspaceCwd)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
@@ -88,12 +202,12 @@ describe('web e2e: agent-preset selection', () => {
await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
// The chip opens on the deployment default, by the name that preset
// publishes rather than its directory name.
expect(snapshot).toContain('标准模式')
expect(snapshot).toContain('Standard mode')
})
it('names every preset and what it is for', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu'))
await page.getByRole('button', { name: '标准模式' }).click()
await page.getByRole('button', { name: 'Standard mode' }).click()
const menu = page.getByRole('menu')
await menu.waitFor({ timeout: 10_000 })
@@ -102,33 +216,59 @@ describe('web e2e: agent-preset selection', () => {
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
// Every shipped preset, each with the sentence saying what it composes —
// the id alone never said what a preset does.
expect(snapshot).toContain('极简模式')
expect(snapshot).toContain('创造模式')
expect(snapshot).toContain('Minimal mode')
expect(snapshot).toContain('Creator mode')
await page.keyboard.press('Escape')
})
it('applies the staged pick to the blank session, and the host honors it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage'))
await page.getByRole('button', { name: '标准模式' }).click()
await page.getByRole('menuitem', { name: /极简模式/ }).click()
await page.getByRole('button', { name: 'Standard mode' }).click()
await page.getByRole('menuitem', { name: /Minimal mode/ }).click()
// The chip stages; the blank session the workspace connect produced is
// what the stage lands on. The host's own answer is what comes back.
await expect.poll(async () => {
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } }
}
return JSON.stringify(body.result.value?.sessions ?? body.result)
}, { timeout: 15_000 }).toContain('minimal')
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal')
})
it('re-reads the slash catalog through the composition the switch installed', async () => {
// Continues the previous case: the chip has already applied `minimal` to
// the blank session, and this one reads the menu that switch left behind.
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
const composer = page.locator('textarea:enabled').last()
// `minimal` mounts neither the compaction group nor plan mode nor local
// skill discovery, so the catalog the composer warmed under the
// deployment default must not survive the switch.
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onMinimal = await menuOptions(page)
expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
// The host-plane commands and the client's own contribution are the
// floor: they belong to no preset and never move.
expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true)
expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
await composer.fill('')
// Switching back up reaches the host at all — the chip compares the pick
// against its list row, so a row that never reprojected the first switch
// answers "already standard" and sends nothing — and restores the catalog
// instead of leaving the session reading the narrower composition.
await page.getByRole('button', { name: 'Minimal mode' }).click()
await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
await composer.fill('/')
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
const onStandard = await menuOptions(page)
expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
await composer.fill('')
}, 90_000)
it('labels a resumed session with the preset it was created under', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
// The seeded session's cwd is the scaffold root rather than the connected
@@ -140,10 +280,12 @@ describe('web e2e: agent-preset selection', () => {
const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('极简模式')
expect(snapshot).toContain('Minimal mode')
expect(snapshot).toContain('button "1 subagent"')
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
// Static chrome, not a control: the header can only report a composition
// the host would refuse to change.
expect(snapshot).not.toContain('button "极简模式"')
expect(snapshot).not.toContain('button "Minimal mode"')
})
it('drove every surface without a page error or a stream warning', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,137 +0,0 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.'
describe('core Web profile', () => {
let scaffold: WebScaffold
let agentHandle: AgentHandle
beforeAll(async () => {
const systemPrompt = process.env.DSH_SYSTEM_PROMPT
Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
try {
scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
} finally {
if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt
}
agentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('core-web-profile-smoke'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
})
afterAll(async () => {
const failures: unknown[] = []
await agentHandle?.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, 'core Web profile smoke teardown failed')
})
it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => {
agentHandle.agent.followup(createUserMessage({
content: [{ type: 'text', text: PROMPT }],
source: { kind: 'user' },
}))
await agentHandle.agent.whenIdle()
const requestHeader = agentHandle.agent.session.requestHeader()
if (requestHeader === undefined) throw new Error('the core Web agent issued no model request')
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
const signal = new AbortController().signal
const bash = await scaffold.ctx.tools.execute({
signal,
callId: CallId('core-web-bash-smoke'),
name: 'bash',
arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
agent: agentHandle.agent,
})
const editor = await scaffold.ctx.tools.execute({
signal,
callId: CallId('core-web-editor-smoke'),
name: 'str_replace_editor',
arguments: { command: 'view', path: seedPath },
agent: agentHandle.agent,
})
const text = (result: typeof bash): string => result.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
.trimEnd()
expect({
prompt: requestHeader.system,
tools: requestHeader.tools?.map(tool => tool.name),
bash: text(bash),
editor: text(editor),
}).toMatchInlineSnapshot(`
{
"bash": "CORE_WEB_BASH_OK",
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
1 CORE_WEB_EDITOR_OK
2",
"prompt": "You are a helpful software engineer assistant.",
"tools": [
"bash",
"str_replace_editor",
],
}
`)
expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent))
const entries = [...scaffold.ctx.loader.entries()]
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined()
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
})
it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => {
const previous = process.env.DSH_SYSTEM_PROMPT
process.env.DSH_SYSTEM_PROMPT = 'RL prompt override'
let overrideScaffold: WebScaffold | undefined
let overrideAgent: AgentHandle | undefined
try {
overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
overrideAgent = await overrideScaffold.ctx.agents.create({
sessionId: SessionId('core-web-profile-override'),
meta: { cwd: overrideScaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
overrideAgent.agent.followup(createUserMessage({
content: [{ type: 'text', text: PROMPT }],
source: { kind: 'user' },
}))
await overrideAgent.agent.whenIdle()
expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override')
} finally {
try {
await overrideAgent?.dispose()
} finally {
try {
await overrideScaffold?.close()
} finally {
if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
else process.env.DSH_SYSTEM_PROMPT = previous
}
}
}
})
})

View File

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

View File

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

View File

@@ -6,8 +6,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { REPO_ROOT } from './support.ts'

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.'
describe('minimal agent preset', () => {
let scaffold: WebScaffold
let agentHandle: AgentHandle
let disposeInjectedPrompt: () => void
beforeAll(async () => {
scaffold = await launchWebScaffold({ replayFixture: FIXTURE })
disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({
name: 'test:injected-prompt',
order: 999,
text: 'THIS TEXT MUST NOT REACH THE MODEL.',
})
agentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('minimal-preset-smoke'),
meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
})
afterAll(async () => {
const failures: unknown[] = []
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
try {
disposeInjectedPrompt?.()
} 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, 'minimal preset smoke teardown failed')
})
it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
agentHandle.agent.followup(createUserMessage({
content: [{ type: 'text', text: PROMPT }],
source: { kind: 'user' },
}))
await agentHandle.agent.whenIdle()
const requestHeader = agentHandle.agent.session.requestHeader()
if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
await mkdir(stateDir)
const signal = new AbortController().signal
await scaffold.ctx.tools.execute({
signal,
callId: CallId('minimal-bash-state-setup'),
name: 'bash',
arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` },
agent: agentHandle.agent,
})
const bash = await scaffold.ctx.tools.execute({
signal,
callId: CallId('minimal-bash-state-read'),
name: 'bash',
arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' },
agent: agentHandle.agent,
})
const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt')
await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n')
const editor = await scaffold.ctx.tools.execute({
signal,
callId: CallId('minimal-editor-smoke'),
name: 'str_replace_editor',
arguments: { command: 'view', path: seedPath },
agent: agentHandle.agent,
})
const text = (result: typeof bash): string => result.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
.trimEnd()
expect({
prompt: requestHeader.system,
tools: requestHeader.tools?.map(tool => tool.name),
bash: text(bash),
editor: text(editor),
}).toMatchInlineSnapshot(`
{
"bash": "PERSISTED:{{cwd}}/persistent-state",
"editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines):
1 MINIMAL_EDITOR_OK
2",
"prompt": "You are a helpful software engineer assistant.",
"tools": [
"bash",
"str_replace_editor",
],
}
`)
expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name)))
.toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name)))
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
})
})

View File

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

View File

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

View File

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

View File

@@ -29,13 +29,12 @@ import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Page } from 'playwright'
import { expect } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import Group from '@cordisjs/plugin-group'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import Group from '@deepseek-ai/cordis-plugin-group'
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
import {
addHarnessSourceSection,
assertEntriesLoaded,
composeEntries,
healProfilesModuleFallback,
@@ -65,6 +64,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
@@ -245,12 +245,21 @@ export interface LaunchOptions {
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
/**
* Mount the shipped telemetry row in FULL mode against this exporter URL
* instead of disabling it. Used to pin a real backend disclosure in
* assembled coverage; point the URL at a local dead endpoint so no record
* leaves the process.
*/
telemetryUrl?: string
/**
* Browse through a trusted non-loopback hostname that the browser resolves
* to loopback (for example `*.localhost`). The test server stays bound to
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
*/
remoteAuthority?: string
/** Reuse an existing harness home so a second Host can verify user settings across origins. */
harnessHome?: string
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -297,16 +306,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
// Skill discovery is model-visible input, and its roots now resolve inside a
// PRESET — a subtree this lane's include patches cannot reach, because the
// roster mounts it directly per session rather than as a row of the booted
// tree. The row's documented fallback is the environment, so pin that: the
// whole scaffold lifetime, not just the boot, since presets mount when a
// session is created. Without this a developer's real ~/.dsh/skills silently
// enters replay requests and goldens while CI sees none.
// enters replay requests and goldens while CI sees none. `DSH_HOME` follows
// the resolved harness home so a scaffold sharing another's home — the
// cross-port persistence scenario — pins the same roots the settings and
// credentials rows were configured with.
const skillRootEnvironment = {
DSH_HOME: join(workspaceCwd, '.dsh-home'),
DSH_HOME: harnessHome,
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
}
@@ -329,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} catch (error) {
const failures: unknown[] = [error]
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
restoreSkillRootEnvironment()
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
@@ -390,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
{ id: 'session-title-llm', disabled: true },
// Fixture sessions must never leave the process: the shipped row defaults
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
// names in the ambient environment). A scenario that pins a real backend
// disclosure passes a local dead endpoint instead of disabling the row.
options.telemetryUrl === undefined
? { id: 'telemetry-otel', disabled: true }
: { id: 'telemetry-otel', config: { exporter: { url: options.telemetryUrl }, shutdownTimeoutMillis: 1_000 } },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0 },
@@ -443,7 +459,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
let replayHandle: ReplayHandle | undefined
try {
process.chdir(workspaceCwd)
// The production resolution shape: an empty profile root inside the temp
// The production module-resolution setup: an empty profile root inside the temp
// harness home, with bare plugin names resolving through the flat module
// fallback the launcher heals under <home>/profiles.
healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome)
@@ -454,19 +470,26 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
ctx.baseUrl = pathToFileURL(profileDir).href + '/'
// This direct Loader harness supplies the same root-path capability as app-boot.
ctx.provide('dshHomePath', dshHomePath)
// A host with no command line still provides one: the web bundle's startup
// row releases the rows waiting on it, and with no arguments each starts on
// the values this scaffold composed above. An exit request can only come
// from a rejected argument, which a fixed empty list has none of.
provideCmdline(ctx, {
args: [],
exit: (code) => {
throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`)
},
})
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
// `cordis:group` beside it, exactly as `boot()` registers it: a group row is
// how a preset gives one `isolate` realm to a provider and its consumers,
// and a preset resolving package names from its own directory cannot reach
// `@cordisjs/plugin-group` by name.
// `@deepseek-ai/cordis-plugin-group` by name.
ctx.loader.builtins.group = Group
// The shipped CLI deliberately has no dependency on this opt-in package.
// Keep the Loader row real without broadening the product installation.
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
if (surfaceContext) {
ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, REPO_ROOT) })
}
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(rootConfig).href, patches },
@@ -621,7 +644,7 @@ export function fixtureUserPrompts(fixtureText: string): string[] {
* through the REAL backend API (throwaway Context + SessionStore + JSONL
* plugin — the semantic-checkpoint precedent), never raw file writes: no
* knowledge of bucket hashing, filename encoding, or compression, and
* malformed shapes fail loud at seed time. The fixture's tokenized identity
* malformed session events fail loud at seed time. The fixture's tokenized identity
* ({{sessionId}}/{{cwd}}) is realized for this world before parsing.
* @param scaffold - the target scaffold.
* @param fixtureText - raw recorded session.jsonl contents.
@@ -725,7 +748,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
// between local worktrees and CI scratch directories.
.replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2')
// Message IconActions clocks widen by calendar day/year; collapse every
// shape so goldens stay stable across midnight and year boundaries.
// format so goldens stay stable across midnight and year changes.
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')

View File

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

View File

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

View File

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

View File

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

View File

@@ -485,10 +485,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
child = spawn(
process.execPath,
[
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web',
// Launcher flags come first: the first token the launcher does not own
// starts the web app's own arguments.
// Pin the in-browser picker: the shipped `-auto` row would resolve to
// the native OS chooser on this bind, and no page can drive that.
'--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
'--port', String(port),
],
{
cwd: sessionsDir,

View File

@@ -20,7 +20,7 @@
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- text: 标准模式 内置 当前使用 功能完整的编码 Agent支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
@@ -30,7 +30,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用
- text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作
- code: code
- 'button "查看: 代码模式"':
- img
@@ -40,7 +40,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent
- code: minimal
- 'button "查看: 极简模式"':
- img
@@ -50,7 +50,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设
- text: 创造模式 内置 用于创建自定义 Agent preset具备标准模式的全部能力并提供运行时检查、插件实验和 preset 创作指导
- code: cordis
- 'button "查看: 创造模式"':
- img
@@ -62,7 +62,7 @@
- list:
- listitem:
- 'button "设为默认: 我的模式"':
- text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现
- text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent
- code: my-agent
- 'button "查看路径: 我的模式"':
- img

View File

@@ -20,7 +20,7 @@
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- text: 标准模式 内置 当前使用 功能完整的编码 Agent支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
@@ -30,7 +30,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用
- text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作
- code: code
- 'button "查看: 代码模式"':
- img
@@ -40,7 +40,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent
- code: minimal
- 'button "查看: 极简模式"':
- img
@@ -50,7 +50,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设
- text: 创造模式 内置 用于创建自定义 Agent preset具备标准模式的全部能力并提供运行时检查、插件实验和 preset 创作指导
- code: cordis
- 'button "查看: 创造模式"':
- img

View File

@@ -20,7 +20,7 @@
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- text: 标准模式 内置 当前使用 功能完整的编码 Agent支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
@@ -30,7 +30,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用
- text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作
- code: code
- 'button "查看: 代码模式"':
- img
@@ -40,7 +40,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现
- text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent
- code: minimal
- 'button "查看: 极简模式"':
- img
@@ -50,7 +50,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设
- text: 创造模式 内置 用于创建自定义 Agent preset具备标准模式的全部能力并提供运行时检查、插件实验和 preset 创作指导
- code: cordis
- 'button "查看: 创造模式"':
- img
@@ -58,6 +58,7 @@
- 'button "复制: 创造模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- button "用「创造模式」创作自定义预设":
- img
- text: 用「创造模式」创作自定义预设

View File

@@ -1,4 +1,7 @@
- navigation "Session hierarchy":
- button "Seeded turn" [disabled]
- img
- text: 极简模式
- text: Minimal mode
- button "1 subagent":
- text: 1 subagent
- img

View File

@@ -2,7 +2,7 @@
- img
- text: workspace
- img
- button "标准模式":
- button "Standard mode":
- img
- text: 标准模式
- text: Standard mode
- img

View File

@@ -1,7 +1,7 @@
- menu:
- menuitem "标准模式 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。":
- text: 标准模式 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.":
- text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.
- img
- menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用。"
- menuitem "极简模式 只向模型呈现 bash str_replace_editor,适合 benchmark 与最小复现。"
- menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。"
- menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
- menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Use only Cordis tools. First" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -0,0 +1,39 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- img
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
- text: Think The user wants me to reply with a single word. Let me comply.
- paragraph: LIGHTHOUSE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."':
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok

View File

@@ -0,0 +1,17 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}}
{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}}
{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}}
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "workspace" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -25,9 +25,9 @@
- img
- text: workspace
- img
- button "标准模式":
- button "Standard mode":
- img
- text: 标准模式
- text: Standard mode
- img
- textbox "Describe what you want to build"
- button "Commands":

View File

@@ -25,9 +25,9 @@
- img
- text: workspace
- img
- button "标准模式":
- button "Standard mode":
- img
- text: 标准模式
- text: Standard mode
- img
- textbox "Describe what you want to build"
- button "Commands":

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,7 +1,7 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"}
{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}","agentPreset":"minimal"}
{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly MINIMAL_PRESET_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}}
{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}}
{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"MINIMAL_PRESET_REQUEST_OK"}}}
{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_PRESET_REQUEST_OK"}}}}
{"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}}
{"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}

View File

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

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "workspace" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -0,0 +1,53 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
- button "Copy":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- button "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- img
- text: Read
- button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
- img
- text: Context injection AGENTS.md
- img
- text: permission preset read-only
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]':
- img
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "/user-invoke-demo and confirm the fixtur" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -17,10 +19,10 @@
- img
- text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
- status: Deep diving...
- text: "Interjection Interjection: include the word BANANA in your final reply."
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply."
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy":
- img
- textbox "Message the agent"

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -19,10 +21,10 @@
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}"
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
- button "Copy":
- img
- paragraph: "Got it: BANANA and ORANGE."

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -23,7 +23,7 @@
- img
- text: Ask question waiting
- status: Deep diving...
- text: "Interjection Interjection: include the word BANANA in your final reply."
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- region "Ready to continue?":

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -21,7 +21,7 @@
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":

View File

@@ -3,6 +3,8 @@
- button "Ask a research subagent to"
- text: /
- button "event-sourcing researcher" [disabled]
- img
- text: Standard mode
- button "1 subagent":
- text: 1 subagent
- img
@@ -41,7 +43,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- 'button "Access mode, current: Custom"': Custom
- button "6% of context used"
- button "Send message" [disabled]
- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok

View File

@@ -3,6 +3,8 @@
- button "Ask a research subagent to"
- text: /
- button "event-sourcing researcher" [disabled]
- img
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -18,6 +20,6 @@
- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled]
- button "Commands" [disabled]:
- img
- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write
- 'button "Access mode, current: Custom" [disabled]': Custom
- button "Stop generating"
- button "Send message" [disabled]

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Begin your reply with the" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Begin your reply with the" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -2,7 +2,7 @@
- navigation "Session hierarchy":
- button "Use web_search to search exactly" [disabled]
- img
- text: 标准模式
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

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

View File

@@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.getByText('Standard mode', { exact: true }).waitFor({ timeout: 10_000 })
}, 120_000)
afterAll(async () => {

View File

@@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect([
Math.round(clickAreaBox!.x - treeBox!.x),
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
]).toEqual([5, 5])
// Menu padding alone insets the rows now that the border is gone.
]).toEqual([4, 4])
await compareOrRefreshGolden(
BRANCHLESS_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
@@ -446,7 +447,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
).toBe(3)
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
expect(await hierarchy.getByRole('button').count()).toBe(1)
await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1)
await compareOrRefreshGolden(
FORK_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@
"exclude": [
"tests/scaffold.ts",
"tests/scaffold-hermetic.e2e.ts",
"tests/core-web-profile.snapshot.ts",
"tests/minimal-preset.snapshot.ts",
"tests/live-interactions.e2e.ts",
"tests/question-composer.e2e.ts",
"tests/approval-composer.e2e.ts",
@@ -64,6 +64,7 @@
"tests/agent-preset-selection.e2e.ts",
"tests/agent-preset-authoring.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/feedback-command.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
"tests/produced-file-mentions.e2e.ts",

View File

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