Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	packages/acp/acp/README.i18n.yaml
#	packages/acp/acp/src/index.ts
#	packages/client/connection/src/client/index.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/client/ui-conversation/src/client/input/contract.ts
#	packages/client/ui-goal/README.i18n.yaml
#	packages/compact/compact-basic/src/index.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent-loop/tests/agent.spec.ts
#	packages/core/agent-loop/tests/loop.spec.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.md
#	packages/core/session/README.zh.md
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/tool-goal/README.i18n.yaml
#	packages/goal/tool-goal/README.md
#	packages/goal/tool-goal/README.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/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/tests/api-proxy-commands.spec.ts
#	packages/host/apiproxy/tests/api-proxy-projections.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-persistence/session-checkpoint-policy/src/index.ts
#	packages/session-persistence/session-persistence/tests/coordinator-contract.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/subagent/subagent-inprocess/README.i18n.yaml
#	packages/subagent/subagent-inprocess/README.md
#	packages/subagent/subagent-inprocess/README.zh.md
#	packages/subagent/subagent-inprocess/src/index.ts
#	packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
#	packages/support/acp-snapshot/src/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
_Kerman
2026-08-03 10:56:09 +08:00
605 changed files with 25740 additions and 2265 deletions

View File

@@ -0,0 +1,132 @@
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { REPO_ROOT } from './support.ts'
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
return {
argv,
cwd,
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
graceMs: 5_000,
...env === undefined ? {} : { env },
}
}
function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
return new Promise((resolveReady, reject) => {
let output = ''
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.stdout?.off('data', onData)
child.stderr?.off('data', onData)
}
const resolveOnce = (value: string): void => {
if (settled) return
settled = true
cleanup()
resolveReady(value)
}
const rejectOnce = (error: Error): void => {
if (settled) return
settled = true
cleanup()
reject(error)
}
const onData = (chunk: Buffer): void => {
output += chunk.toString()
const match = pattern.exec(output)
if (match === null) return
resolveOnce(match[1] ?? match[0])
}
const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
child.stdout?.on('data', onData)
child.stderr?.on('data', onData)
void child.done.then((outcome) => {
rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
}, (error: unknown) => {
rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
})
})
}
async function stopTree(child: SubprocessHandle): Promise<void> {
child.terminate()
const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
await child.done
}
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
const oldText = "Let's start building"
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
const subprocessCtx = new Context()
let subprocessFiber: Fiber | undefined
let watcher: SubprocessHandle | undefined
let host: SubprocessHandle | undefined
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
const failures: unknown[] = []
try {
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
world,
{
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
DSH_HOME: join(world, '.dsh'),
},
))
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
browser = await chromium.launch()
const page = await browser.newPage()
const pageErrors: string[] = []
page.on('pageerror', error => pageErrors.push(String(error)))
await page.goto(baseUrl, { waitUntil: 'load' })
await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
const pageIdentity = await page.evaluate(() => {
const identity = crypto.randomUUID()
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
return identity
})
await writeFile(sourcePath, updatedSource)
await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
.toBe(pageIdentity)
expect(pageErrors).toEqual([])
} catch (error) {
failures.push(error)
} finally {
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
await browser?.close().catch((error: unknown) => failures.push(error))
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
}, 120_000)

View File

@@ -158,8 +158,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const observeTurn = async () => {
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
try {
await input.press('Enter')
if (MODE !== 'record') {
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
await expect.poll(async () => await liveTail.evaluate(element => (
element.scrollWidth > element.clientWidth
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
)), { timeout: 10_000, interval: 10 }).toBe(true)
}
return await settled
} finally {
if (MODE !== 'record') await page.setViewportSize(originalViewport)
}
}
const sessionId = await observeTurn()
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}

View File

@@ -67,6 +67,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
// the ready projection replaces.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterAll(async () => {

View File

@@ -22,6 +22,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
@@ -172,10 +173,95 @@ describe('web e2e: queue row actions', () => {
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
}, 120_000)
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
const readyFile = join(overrideDir, '.hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
const input = page.locator('textarea').first()
const settled = scaffold.whenTurnSettled()
await input.fill('/goal Keep the composer context panels aligned')
await input.press('Enter')
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
const sessions = scaffold.ctx.sessions.list()
expect(sessions).toHaveLength(1)
sessions[0]!.append('todo/write', {
todos: [
{ content: 'Confirm the panel order', status: 'completed' },
{ content: 'Align the panel widths', status: 'in_progress' },
],
})
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
for (const text of ['Layout queue first', 'Layout queue second']) {
await input.fill(text)
await input.press('Enter')
}
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
const layoutSnapshot = await captureStableAria(
page,
'[class*="centerCol"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
const expectAlignedContextPanels = async () => {
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
expect(queuePanelBox).not.toBeNull()
expect(todoBox).not.toBeNull()
expect(goalBox).not.toBeNull()
expect(todoBox!.y).toBeLessThan(goalBox!.y)
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
}
await expectAlignedContextPanels()
await page.setViewportSize({ width: 640, height: 1000 })
await expectAlignedContextPanels()
await page.setViewportSize({ width: 1680, height: 1000 })
await queueHeader.click()
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
await removeButtons.first().click()
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
await removeButtons.first().click()
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Clear goal' }).click()
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(
SNAPSHOT_DIR,
['collapsed.expected.md', 'editing.expected.md', 'preserved.expected.md', 'ui.expected.md'],
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
)
})
})

View File

@@ -9,20 +9,23 @@
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// The scenario's one drive prompt. Record sends it; replay asserts the
@@ -35,6 +38,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let settledSessionId: SessionId | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
@@ -69,11 +73,44 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
settledSessionId = sessionId
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 200_000)
it('records the Web surface, source checkout, and session cwd in the request header', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
const system = agent.session.requestHeader()?.system
if (system === undefined) throw new Error('the settled Web request has no system prompt')
const prefix = system.split('\n\n').slice(0, 4).join('\n\n')
.split(REPO_ROOT).join('{{sourceRoot}}')
.split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}')
.split(scaffold.baseUrl).join('{{webUrl}}')
await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE)
})
it('exposes the assembled Web URL to the real bash tool', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
const result = await scaffold.ctx.tools.execute({
signal: AbortSignal.timeout(5_000),
callId: CallId('web-url-probe'),
name: 'bash',
arguments: {
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
description: 'Print current Web runtime',
},
agent,
})
expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe(`${scaffold.baseUrl}\nproduction\n`)
})
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
// Browser settled-poll after host completion (host strictly precedes render).
@@ -129,6 +166,6 @@ describe('web e2e: fresh round trip through the real assembly', () => {
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md'])
})
})

View File

@@ -53,6 +53,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 { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
@@ -120,6 +121,11 @@ export interface LaunchOptions {
* mounts).
*/
replayFixture?: string
/**
* Recorded child logs assigned in child creation order. Each child owns its
* own positional replay cursor across initial and continuation turns.
*/
replayChildFixtures?: string[]
/**
* Optional replay.override.json sidecar (whole-script replacement or
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
@@ -300,6 +306,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// 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
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
@@ -326,6 +333,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}

View File

@@ -2,8 +2,9 @@
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// and the Language row (settings-scoped localization + persisted dsh.locale),
// plus Permission as the persisted default for subsequently created sessions.
// the Language row (settings-scoped localization + persisted dsh.locale),
// the busy-state Enter preference, 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.
@@ -182,6 +183,32 @@ 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 () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '排队发送' }).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')
await page.keyboard.press('Escape')
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '设置', exact: true }).click()
const reloaded = page.getByRole('dialog', { name: '设置' })
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
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')
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('switches the settings surface language and persists dsh.locale', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
await page.getByRole('button', { name: '设置', exact: true }).click()

View File

@@ -28,13 +28,10 @@ const EXPECTED_TOOLS = [
'edit',
'exit_plan_mode',
'get_goal',
'list_agents',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'send_message',
'skill',
'str_replace_editor',
'subagent',

View File

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

View File

@@ -1,5 +1,6 @@
- banner:
- 'heading "Using ONE run_code program: run" [level=1]'
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use only Cordis tools. First" [level=1]
- navigation "Session hierarchy":
- button "Use only Cordis tools. First" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

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

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the bash tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with the single word" [level=1]
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the read tool twice" [level=1]
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- 'heading "Plan a small change: add" [level=1]'
- navigation "Session hierarchy":
- 'button "Plan a small change: add" [disabled]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the ask_user_question tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -22,6 +23,8 @@
- img
- button "Remove queued message":
- img
- button "Steer queued message":
- img
- listitem:
- textbox "Edit queued message": Edited queue item
- button "Save queued message":

View File

@@ -0,0 +1,43 @@
- banner:
- navigation "Session hierarchy":
- button "workspace" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
- img
- img
- text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
- button "Context injection":
- img
- img
- text: Context injection
- button "Context injection":
- img
- img
- text: Context injection
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- region "To-dos":
- button "To-dos 1/2 tasks · 1 in progress"
- img
- text: Ongoing Goal Keep the composer context panels aligned
- button "Pause goal":
- img
- button "Edit goal":
- img
- button "Clear goal":
- img
- button "2 queued messages"
- 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 "Stop generating"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -32,6 +33,8 @@
- img
- button "Remove queued message":
- img
- button "Steer queued message":
- img
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -21,6 +22,8 @@
- img
- button "Remove queued message":
- img
- button "Steer queued message":
- img
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the read tool twice" [level=1]
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the read tool twice" [level=1]
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -28,3 +28,7 @@
- button "跟随系统" [pressed]:
- img
- text: 跟随系统
- text: 繁忙时 Enter 键行为 仅在智能体运行时生效Cmd/Ctrl+Enter 使用另一行为
- button "排队发送":
- text: 排队发送
- img

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the ask_user_question tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -22,6 +23,9 @@
- img
- text: Ask question waiting
- status: Deep diving...
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the ask_user_question tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -20,7 +21,11 @@
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection: include the word BANANA in your final reply."
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img

View File

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

View File

@@ -0,0 +1,18 @@
- banner:
- navigation "Session hierarchy":
- button "Ask a research subagent to"
- text: /
- button "event-sourcing researcher"
- text: /
- button "example editor" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Give one concrete event sourcing example. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- status:
- strong: This subagent is read-only for now
- text: The parent session is offline; reopen it to continue sending messages.

View File

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

View File

@@ -0,0 +1,3 @@
- tree "Subagent sessions":
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…

View File

@@ -0,0 +1,8 @@
- tree "Subagent sessions":
- treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running just now" [expanded] [level=1]:
- button "Collapse event-sourcing researcher descendants":
- img
- text: event-sourcing researcher Explain event sourcing in one · continuable · not running just now
- group:
- treeitem "example editor continuable · not running just now" [level=2]
- treeitem "event-sourcing reviewer one-shot · not running just now" [level=1]

View File

@@ -0,0 +1,50 @@
- banner:
- navigation "Session hierarchy":
- button "Ask a research subagent to"
- text: /
- button "event-sourcing researcher" [disabled]
- button "1 subagent":
- text: 1 subagent
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Explain event sourcing in one sentence. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Now give the same explanation to a human reader. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Send message" [disabled]
- text: 2 turns · 2 steps Context 6% of 128K Cache hit 99% Input 15.6K tok · Output 158 tok

View File

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

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use web_search to search exactly" [level=1]
- navigation "Session hierarchy":
- button "Use web_search to search exactly" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -1,15 +1,7 @@
// Web e2e scenario: mid-turn steering over the host wire. The Web UI has no
// steer entry, so the steer is POSTed from the page over the same
// same-origin /api transport the client uses. Everything downstream is
// product: the gateway routes mode:'steer' to Agent.steer, the loop drains
// it at the step boundary into a durable steering/message event, the SSE mux
// pushes it, and the transcript shows the text as a plain bubble (no
// interjection chrome). The question composer supplies the deterministic
// mid-turn window: while ask_user_question blocks, the turn is provably
// running, so record and replay perform the identical steer-then-answer
// sequence with zero timing dependence — and the recorded final reply proves
// the steer reached the MODEL (it obeys an instruction that only the
// steering message carries).
// Web e2e scenarios for both steering entry points: QueueDock strictly
// transfers one queued occurrence, while the complementary composer gestures
// choose Queue or Steer. The question tool supplies a deterministic pending-
// steering snapshot before the step can drain.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -26,16 +18,18 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// Two goldens for the two distinct states this interaction produces: the
// mid-turn moment (steer ACCEPTED but deliberately invisible the loop
// drains steering at the step boundary, so no steering text exists while
// the question still blocks the step) and the settled transcript (plain
// bubble in place, final reply obeying it). The pair pins the timing
// semantics visually: if the client ever starts rendering pending steers
// eagerly, the mid-steer golden flips first.
// Two goldens pin the transient Host projection and its durable handoff: the
// mid-turn state renders accepted steering from session/queue while the
// question blocks admission, then the settled state renders the same message
// from steering/message beside the reply that obeys it.
const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const MODE = webSnapshotMode()
// The question composer replaces the textarea, so fill → Queue row → Steer
// must finish inside the first replay chunk window. At 15 ms that window is
// shorter than Playwright's round trips; 100 ms supplies test-only headroom,
// while larger values lengthen all three replay scenarios linearly.
const REPLAY_PACE_MS = 100
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
const STEER = 'Interjection: include the word BANANA in your final reply.'
@@ -56,15 +50,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let liveSessionId: string | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (session, event) => {
liveSessionId ??= session.id
sessionEvents.push(event)
})
scaffold = await launchWebScaffold(MODE === 'record'
? {}
: { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
@@ -79,7 +71,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
await scaffold?.close()
})
it('steers during the blocked step; the message is logged, rendered, and obeyed', async () => {
it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
if (MODE !== 'record') {
// The steer must NOT be a user/message — it lands as steering/message.
@@ -91,36 +83,29 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
await input.fill(PROMPT)
await input.press('Enter')
// The blocked composer is the mid-turn barrier: its presence proves the
// ask_user_question step is executing, i.e. the turn is running NOW.
// Enter remains the Queue gesture. The row action then atomically moves
// this exact occurrence into the current turn's steering outbox.
await input.fill(STEER)
await input.press('Enter')
const queued = page.getByText(STEER, { exact: true })
await queued.waitFor({ timeout: 10_000 })
const queuedRow = page.getByRole('listitem').filter({ hasText: STEER })
const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' })
await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true)
await steerButton.click({ timeout: 10_000 })
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
// A timeout while the Queue row remains means strict steer lost to a
// closing window (`steer-unavailable`); inspect replay pacing first.
await pendingSteering.waitFor({ timeout: 10_000 })
// The blocked composer keeps steering pending long enough to observe the
// Host-authoritative mirror before the loop admits it durably.
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
// Steer through the real wire from the page (same envelope + endpoint the
// web client's session.prompt uses). accepted:true is the transport proof.
expect(liveSessionId).toBeDefined()
const reply = await page.evaluate(async ({ sessionId, text }) => {
const response = await fetch('/api/session.prompt', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: crypto.randomUUID(),
method: 'session.prompt',
payload: { sessionId, mode: 'steer', content: [{ type: 'text', text }] },
}),
})
return await response.json() as { result?: { ok?: boolean } }
}, { sessionId: liveSessionId!, text: STEER })
expect(reply.result?.ok).toBe(true)
if (MODE !== 'record') {
// Mid-turn golden: the ACCEPTED steer is durable in the inbox but the
// loop drains steering only at the step boundary, so no steering/message
// exists yet and no steer text renders — the composer still blocks,
// alone. The DOM is stable here (no further SSE frames can arrive until
// the question is answered), making this state capturable.
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
expect(await pendingSteering.count()).toBe(1)
expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE)
@@ -156,6 +141,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
// Visible: the plain steering bubble plus the reply that obeys it
// (steer text + final reply each contain the marker word).
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await pendingSteering.count()).toBe(0)
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.locator('[data-question-key]').count()).toBe(0)
// Settled golden: steer text between the question round trip and the
@@ -170,3 +156,120 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md'])
})
})
describe('web e2e: composer shortcut steers directly', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering'))
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled(30_000)
await input.fill(PROMPT)
await input.press('Enter')
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
await input.fill(STEER)
await input.press('Meta+Enter')
await expect.poll(() => input.inputValue(), { timeout: 5_000 }).toBe('')
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: 30_000 })
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
await pendingSteering.waitFor({ timeout: 10_000 })
await composer.getByRole('radio', { name: 'Yes' }).click()
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
await settled
const steerEvents = sessionEvents.filter(event => event.type === 'steering/message')
expect(steerEvents).toHaveLength(1)
expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await pendingSteering.count()).toBe(0)
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(2)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
})
describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('queues Cmd+Enter when plain Enter is configured to Steer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-swapped-shortcut'))
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.getByRole('button', { name: 'Queue' }).click()
await page.getByRole('menuitem', { name: 'Steer' }).click()
await dialog.getByRole('button', { name: 'Steer' }).waitFor({ timeout: 10_000 })
await page.keyboard.press('Escape')
const input = page.locator('textarea').first()
const settled = scaffold.whenTurnSettled(30_000)
await input.fill(PROMPT)
await input.press('Enter')
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
const queuedText = 'Queued by the complementary Cmd+Enter shortcut.'
await input.fill(queuedText)
await input.press('Meta+Enter')
const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText })
await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 })
expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0)
expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0)
// Remove the asserted Queue row, then finish the recorded question turn
// so replay teardown still proves that every fixture call was consumed.
await queuedRow.getByRole('button', { name: 'Remove queued message' }).click()
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: 30_000 })
await composer.getByRole('radio', { name: 'Yes' }).click()
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
await settled
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
})

View File

@@ -0,0 +1,486 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 {
SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import {
acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const LABEL = 'event-sourcing researcher'
const ONE_SHOT_LABEL = 'event-sourcing reviewer'
const NESTED_LABEL = 'example editor'
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
const [header, ...eventLines] = source.trimEnd().split('\n')
if (header === undefined) throw new Error('base replay fixture has no header')
const childHeader = header
.replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
.replace(/"createdAt":\d+/, '"createdAt":1784998084442')
if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
const continued = eventLines.map(line => line
.replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
.replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
.replaceAll('"turn":1', '"turn":2'))
return [childHeader, ...eventLines, ...continued, ''].join('\n')
}
async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise<void> {
const deadline = Date.now() + 30_000
while (scaffold.ctx.agents.get(id) !== undefined) {
if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
describe('web e2e: persisted subagent conversation and human continuation', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let sidecarRoot: string
let childId: SessionId
let oneShotId: SessionId
let grandchildId: SessionId
let tripwire: ReturnType<typeof watchConsole>
const apiCalls: string[] = []
beforeAll(async () => {
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
const childFixturePath = join(sidecarRoot, 'child.jsonl')
await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
scaffold = await launchWebScaffold({
replayFixture: BASE_FIXTURE,
replayChildFixtures: [childFixturePath],
paceMs: 25,
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
page.on('request', (request) => {
const path = new URL(request.url()).pathname
if (path.startsWith('/api/')) apiCalls.push(path)
})
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const parent = scaffold.ctx.agents.roots()[0]
if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
const parentSettled = scaffold.whenTurnSettled()
const parentInput = page.locator('textarea:enabled').first()
await parentInput.fill(PARENT_PROMPT)
await parentInput.press('Enter')
expect(await parentSettled).toBe(parent.id)
const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn',
label: LABEL,
signal: new AbortController().signal,
request: {
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
parent,
},
})
childId = started.childId
await waitForAgentToSettle(scaffold, childId)
oneShotId = sessionId('recorded-one-shot')
const oneShotAt = Date.now()
await scaffold.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: oneShotId,
createdAt: oneShotAt,
cwd: scaffold.workspaceCwd,
parentSession: parent.id,
origin: 'subagent',
delegationDepth: 1,
})
await scaffold.ctx.sessionPersistence.append(oneShotId, [
{
type: 'turn/start',
seq: 0,
time: oneShotAt,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'user/message',
seq: 1,
time: oneShotAt + 1,
data: {
content: [{ type: 'text', text: 'Review the event sourcing explanation.' }],
source: { kind: 'user' },
},
surfaceOp: 'append',
},
{
type: 'subagent/descriptor',
seq: 2,
time: oneShotAt + 2,
data: snapshotSubagentDescriptor({
mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL,
}),
},
{
type: 'turn/end',
seq: 3,
time: oneShotAt + 3,
data: { turn: 1, reason: { kind: 'completed' } },
},
] as SessionEvent[])
grandchildId = sessionId('recorded-grandchild')
const authoredAt = Date.now()
await scaffold.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: grandchildId,
createdAt: authoredAt,
cwd: scaffold.workspaceCwd,
parentSession: childId,
origin: 'subagent',
delegationDepth: 2,
})
await scaffold.ctx.sessionPersistence.append(grandchildId, [
{
type: 'turn/start',
seq: 0,
time: authoredAt,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'user/message',
seq: 1,
time: authoredAt + 1,
data: {
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
source: { kind: 'user' },
},
surfaceOp: 'append',
},
{
type: 'subagent/descriptor',
seq: 2,
time: authoredAt + 2,
data: snapshotSubagentDescriptor({
mode: 'continuable', provider: 'spawn', label: NESTED_LABEL,
}),
},
{
type: 'turn/end',
seq: 3,
time: authoredAt + 3,
data: { turn: 1, reason: { kind: 'completed' } },
},
] as SessionEvent[])
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
{
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
activity: 'inactive', hasChildren: true,
},
{
kind: 'child', id: oneShotId, mode: 'one-shot',
label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
},
])
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
{
kind: 'child', id: grandchildId, mode: 'continuable',
label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
},
])
// These two cold fixtures were authored after the page's initial
// session.list and intentionally emitted no session-added frame. Reload
// to exercise the restart baseline that discovers their full lineage.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const catalogButton = page.getByRole('button', { name: /subagents/ })
await catalogButton.waitFor({ timeout: 15_000 })
await catalogButton.click()
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
await catalogTree.press('Escape')
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (sidecarRoot !== undefined) {
await rm(sidecarRoot, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
})
it('keeps known descendants reachable across a stale empty catalog response', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
const pattern = '**/api/subagent.list'
let firstClaimed = false
let emptyDelivered = false
let trailingRequested = false
let releaseCatalog = (): void => {}
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
await page.route(pattern, async (route) => {
if (firstClaimed) {
const response = await route.fetch()
trailingRequested = true
await catalogHeld
await route.fulfill({ response })
return
}
firstClaimed = true
const response = await route.fetch()
const body = await response.json() as {
result: { ok: true; value: { entries: unknown[] } } | { ok: false }
}
if (body.result.ok) body.result.value.entries = []
await route.fulfill({ response, json: body })
emptyDelivered = true
})
const warningStart = tripwire.warnings.length
try {
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '3 subagents' }).click()
await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
await compareOrRefreshGolden(
STALE_CATALOG_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
MODE,
)
releaseCatalog()
await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
await tree.press('Escape')
} finally {
releaseCatalog()
await page.unroute(pattern)
}
})
it('expands a persisted grandchild progressively without activating either level', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
await page.getByRole('button', { name: '3 subagents' }).click()
expect(await page.getByRole('button', {
name: `Expand ${ONE_SHOT_LABEL} descendants`,
}).count()).toBe(0)
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
const snapshot = await captureStableAria(
page,
'[role="tree"][aria-label="Subagent sessions"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
})
it('opens the completed child from persistence without activating it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await expect.poll(
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
{ timeout: 15_000 },
).toBe(1)
if (scaffold.ctx.agents.get(childId) !== undefined) {
throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
}
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor()
const sidebar = await captureStableAria(
page,
'[role="tree"][aria-label="Sessions"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
})
it('continues through FIFO follow-up admission and receives the child mux events', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
const ended = new Promise<void>((resolveEnded, reject) => {
const timer = setTimeout(() => {
off()
reject(new Error('subagent follow-up did not reach turn/end'))
}, 30_000)
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
if (session.id !== childId || event.type !== 'turn/end') return
clearTimeout(timer)
off()
resolveEnded()
})
})
const input = page.getByRole('textbox', { name: 'Message the agent' })
await input.fill(FOLLOWUP)
await input.press('Enter')
await expect.poll(
() => scaffold.ctx.agents.get(childId)?.status,
{ timeout: 10_000 },
).toBe('running')
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
await hierarchy.getByRole('button').first().click()
const runningTrigger = page.getByRole('button', { name: '3 subagents running' })
await runningTrigger.waitFor({ timeout: 10_000 })
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
await runningTrigger.click()
await page.getByRole('treeitem', {
name: new RegExp(`${LABEL}.*running`),
}).waitFor({ timeout: 10_000 })
await ended
await page.getByRole('treeitem', {
name: new RegExp(`${LABEL}.*not running`),
}).waitFor({ timeout: 10_000 })
expect(await page.getByRole('button', { name: '3 subagents' })
.locator('[data-state="ongoing"]').count()).toBe(0)
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
})
it('matches the settled addressed-conversation aria golden and stays clean', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('opens an unavailable persisted grandchild after recording the available child', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
await page.getByRole('button', { name: '1 subagent' }).click()
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
const crumbs = await hierarchy.getByRole('button').allTextContents()
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
await compareOrRefreshGolden(
UNAVAILABLE_GRANDCHILD_EXPECTED,
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
MODE,
)
})
it('opens a one-shot child as permanently read-only history', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
const parentSession = page.getByRole('tree', { name: 'Sessions' })
.getByRole('treeitem')
.last()
await parentSession.click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
})
it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork'))
await page.getByRole('tree', { name: 'Sessions' })
.getByRole('treeitem', { name: /Ask a research subagent to/ })
.click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
const forkResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/session.fork')
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
expect(forkReceipt.result).toMatchObject({ ok: true })
await expect.poll(
() => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
{ timeout: 15_000 },
).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 compareOrRefreshGolden(
FORK_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
MODE,
)
})
it('cold-resumes the original subagent while its ordinary fork stays active', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.locator('textarea:enabled').first().waitFor()
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
const forkResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/session.fork')
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
const forkReceipt = await (await forkResponse).json() as {
result: { ok: true; value: { sessionId: string } } | { ok: false }
}
expect(forkReceipt.result).toMatchObject({ ok: true })
if (!forkReceipt.result.ok) return
const forkId = sessionId(forkReceipt.result.value.sessionId)
await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
await page.getByRole('button', { name: '3 subagents' }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.locator('textarea:enabled').first()
await input.waitFor()
const promptResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subagent.prompt')
await input.fill(POST_FORK_FOLLOWUP)
await input.press('Enter')
const promptReceipt = await (await promptResponse).json() as {
result: { ok: true } | { ok: false; error: { code: string; message: string } }
}
if (!promptReceipt.result.ok) {
throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`)
}
await expect.poll(async () => {
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
const messageIndex = loaded.events.findIndex(event => event.type === 'user/message'
&& event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP))
return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end')
}, { timeout: 30_000 }).toBe(true)
expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
})
})

View File

@@ -0,0 +1,9 @@
import { appendFileSync } from 'node:fs'
import { Server } from 'node:net'
const marker = process.env.DSH_LISTEN_PROBE_MARKER
const listen = Server.prototype.listen
Server.prototype.listen = function (...args) {
if (marker !== undefined) appendFileSync(marker, 'listen\n')
return listen.apply(this, args)
}

View File

@@ -0,0 +1,63 @@
/** Bare Vite must fail before it can present a bootless shell as a working GUI. */
import { fileURLToPath, pathToFileURL } from 'node:url'
import { join } from 'node:path'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { createServer } from 'node:net'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
const WEB_ROOT = fileURLToPath(new URL('..', import.meta.url))
/** Reserve an available loopback port, then release it for the child invocation. */
async function freePort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('port probe returned no address')
await new Promise<void>((resolve, reject) => server.close((error) => {
if (error === undefined) resolve()
else reject(error)
}))
return address.port
}
describe('Web development entry', () => {
it('rejects the package dev alias with the full-host correction', async () => {
const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false })
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain('apps/web is not a standalone application')
expect(result.stderr).toContain('dsh web')
})
it('rejects the standalone Vite server with the full-host correction', async () => {
const probeRoot = mkdtempSync(join(tmpdir(), 'dsh-vite-listen-probe-'))
const marker = join(probeRoot, 'listen-called')
const port = await freePort()
try {
const probeModule = fileURLToPath(new URL('./support/listen-probe.mjs', import.meta.url))
const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], {
cwd: WEB_ROOT,
reject: false,
timeout: 10_000,
env: {
...process.env,
DSH_LISTEN_PROBE_MARKER: marker,
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import ${pathToFileURL(probeModule).href}`.trim(),
},
})
expect(result.timedOut).toBe(false)
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain('apps/web is not a standalone application')
expect(result.stderr).toContain('dsh web')
expect(result.stderr).toContain('window.__DSH_BOOT__')
expect(existsSync(marker), 'Vite called Server.listen before rejecting standalone serve mode').toBe(false)
} finally {
rmSync(probeRoot, { recursive: true, force: true })
}
})
})

View File

@@ -38,6 +38,7 @@
"tests/onboarding-deepseek-config.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/code-mode-round.e2e.ts",
@@ -51,7 +52,8 @@
"tests/access-confirmation.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/goal-bar.e2e.ts",
"tests/startup-auto-selection.e2e.ts"
"tests/startup-auto-selection.e2e.ts",
"tests/subagent-conversation.e2e.ts"
],
"references": [
{

View File

@@ -1,11 +1,25 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
+ 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). '
+ 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.'
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
function rejectStandaloneServe(): Plugin {
return {
name: 'dsh-reject-standalone-web-serve',
config(_config, env) {
if (env.command === 'serve') throw new Error(STANDALONE_ERROR)
},
}
}
export default defineConfig({
plugins: [react()],
plugins: [rejectStandaloneServe(), react()],
resolve: {
// Workspace packages resolve to SOURCE: package.json exports point at lib
// for Node/type consumers, but the browser bundle must compile src directly