Merge remote-tracking branch 'github/master' into feat/web-queue-steer-all

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
This commit is contained in:
_Kerman
2026-08-10 10:21:56 +08:00
3852 changed files with 105025 additions and 31302 deletions

View File

@@ -9,8 +9,8 @@
// model content as the question composer: the turn cannot complete without it).
//
// Geometry is the point of the scenario. The command is unbounded model text,
// and before the cap a long one grew the card until the refuse/allow buttons
// left the viewport — an approval the user could see and not answer.
// and an uncapped card grows with it until the refuse/allow buttons leave the
// viewport — an approval the user could see and not answer.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -36,8 +36,8 @@ 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 — a repeated word is what the
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
// screen. Unrelated tokens, not a repeated word — the model compresses a
// repeated word into `printf 'alpha %.0s' {1..400}` when 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(' ')
@@ -89,7 +89,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
await input.fill('')
// Read-only: the mode whose denial the model escalates from. Switched
// through the shipped access-mode chip, not a test-only seam.
// through the shipped access-mode chip, not a test-only override.
await page.locator('[aria-label^="Access mode"]').click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await expect.poll(
@@ -115,9 +115,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
// The regression this scenario exists for: an uncapped card grew with
// the command until the action row left the viewport. Measured at the
// lane baseline and at a short viewport, on the live panel.
// The uncapped-card hazard the header names, measured at the lane
// baseline and at a short viewport, on the live panel.
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
for (const height of [1000, 700]) {
await page.setViewportSize({ width: 900, height })

View File

@@ -1,5 +1,5 @@
// Shared scaffolding for the assembled-jsdom snapshots: the real built
// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's
// workspace `lib/client.js` artifacts booted through AppWebEntry's
// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
// transport. Every file that mounts this graph needs the same boot entry list,
// the same bundle map, the same jsdom globals, and the same mount call, and
@@ -14,18 +14,22 @@ import { afterEach, beforeEach, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
dir: 'ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',
url: '/plugins/ui-workspace.js',
rev: 'fx',
inject: [
@@ -34,12 +38,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
'@deepseek-ai/dsh-client-ui-sidebar',
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'),
]))
interface FixtureWindow extends Window {
@@ -97,7 +101,7 @@ export function mountAssembledApp(): void {
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
loadBundle: async (url) => {
@@ -115,7 +119,7 @@ export function mountAssembledApp(): void {
* Match a CSS-module class by its logical name.
* Module class names carry a per-build hash in one of two schemes —
* ui-primitives emits `_<name>_<hash>` (name bounded by underscores),
* ui-conversation emits `<hash>_<name>` (name at the end) — and a longer name
* feature bundles emit `<hash>_<name>` (name at the end) — and a longer name
* containing this one must not match (`line` must not hit `lineNumber`).
* @param el - element whose class list is inspected.
* @param name - logical (unhashed) module class name.

View File

@@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
// Resolve the resident approval so the ordinary composer bar (which owns
// ContextMeter) resumes without replacing the session shell. This minimal
// boot graph intentionally does not mount the separate question UI plugin.
@@ -95,7 +94,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
.map(style => style.getAttribute('data-plugin'))
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-tool']) {
expect(styleOwners).toContain(plugin)
}
})

View File

@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import {
launchWebScaffold,
watchConsole,
@@ -160,6 +161,14 @@ function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>):
.join('')
}
function messageKey(event: SessionEvent<'user/message'>): string {
return conversationContextKey('input-message', String(event.data.id))
}
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
}
describe('web e2e: continuous conversation grown through the composer', () => {
let browser: Browser
let page: Page
@@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& userText(event).includes(spec.userMarker)
)), { timeout: 15_000 }).toBe(true)
const echoedUser = sessionEvents.slice(eventStart).find(
(event): event is SessionEvent<'user/message'> => (
event.type === 'user/message'
@@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
),
)
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await userRow.textContent()).toContain(spec.userMarker)
@@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')

View File

@@ -1,6 +1,7 @@
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
// input only navigates to the semantic target; assertions pin content identity
// and interaction routing rather than scroll geometry or mounted row counts.
// Long-history Chat behavior contract that stays valid under a virtualized
// renderer: wheel input only navigates to the semantic target; assertions pin
// content identity and interaction routing rather than scroll geometry or
// mounted row counts.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
@@ -115,6 +117,18 @@ function requiredEvent<T extends SessionEvent['type']>(
return event
}
function messageKey(event: SessionEvent<'user/message'>): string {
return conversationContextKey('input-message', String(event.data.id))
}
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
}
function turnTailKey(turn: number): string {
return conversationContextKey('turn-tail', String(turn))
}
describe('web e2e: long Chat interaction contract', () => {
let browser: Browser
let page: Page
@@ -176,8 +190,10 @@ describe('web e2e: long Chat interaction contract', () => {
const expectedUserText = textContent(branchUserEvent.data.content)
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
const toolUserKey = messageKey(toolUserEvent)
const toolAssistantKey = assistantKey(toolAssistantEvent)
const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
@@ -186,28 +202,27 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await call1.count()).toBe(1)
expect(await call2.count()).toBe(1)
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
expect(await call1.textContent()).toContain(toolMarker1)
expect(await call2.textContent()).toContain(toolMarker2)
const expectedOrder = [
`node:${String(toolUserEvent.seq)}`,
`call:${TARGET_CALL_1}`,
`call:${TARGET_CALL_2}`,
`node:${String(toolAssistantEvent.seq)}`,
toolUserKey,
conversationContextKey('tool-call', TARGET_CALL_1),
conversationContextKey('tool-call', TARGET_CALL_2),
toolAssistantKey,
]
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
.filter((key): key is string => key !== undefined && keys.includes(key))
), expectedOrder)
expect(actualOrder).toEqual(expectedOrder)
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
))))
expect(groupKeys[0]).not.toBeNull()
expect(groupKeys[1]).toBe(groupKeys[0])
expect(toolKinds).toEqual(['tool-call', 'tool-call'])
const summary1 = call1.locator('[data-sample="bash"]')
const summary2 = call2.locator('[data-sample="bash"]')
@@ -219,9 +234,12 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
const branchUserKey = messageKey(branchUserEvent)
const branchAssistantKey = assistantKey(branchAssistantEvent)
await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
expect(await userRow.textContent()).toContain(branchUserMarker)
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
@@ -230,8 +248,8 @@ describe('web e2e: long Chat interaction contract', () => {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
.toBe(expectedUserText)
await assistantRow.hover()
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await turnTailRow.hover()
await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
{ timeout: 15_000 },

View File

@@ -40,6 +40,11 @@ const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
const HISTORY_FIXTURE = createChatScrollFixture({
markerPrefix: 'HISTORY',
@@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({
title: 'CHAT_SCROLL_RESTORE_B comparison session',
turns: 32,
})
const INPUTS_FIXTURE = createChatScrollFixture({
markerPrefix: 'INPUTS',
title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
})
interface ScrollGeometry {
readonly distanceFromBottom: number
@@ -273,6 +282,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
await nextPaint(page)
}
/**
* Touch-style momentum fling over the transcript. Headless Chromium in the
* test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture
* and Input.dispatchTouchEvent both deliver DOM events without moving any
* scroller, and compositor scrollbars ignore synthetic mouse input), so the
* fling replays the signature a real pan leaves on the scrollport: per-frame
* decaying displacements the component never authored, carrying no wheel
* events. Wheel-sign semantics: positive deltaY reads downward.
*/
async function flingTranscript(page: Page, deltaY: number): Promise<void> {
await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => {
const direction = Math.sign(delta)
let remaining = Math.abs(delta)
// Fast launch decaying toward a floor speed, like a released finger. The
// floor stays above the follow threshold so contended frames (streaming
// writes racing the fling) still deviate far enough to read as input.
let velocity = Math.max(120, remaining / 8)
while (remaining > 0) {
const step = Math.min(velocity, remaining)
host.scrollTop += direction * step
remaining -= step
velocity = Math.max(48, velocity * 0.9)
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
}
}, deltaY)
await nextPaint(page)
}
async function wheelToHistoryStart(page: Page): Promise<void> {
for (let attempt = 0; attempt < 12; attempt += 1) {
if ((await scrollGeometry(page)).scrollTop <= 1) break
@@ -683,4 +720,112 @@ describe('web e2e: long Chat scroll contract', () => {
assertClean(world)
})
}, 180_000)
// Keyboard is the only non-wheel device this lane's Chromium can drive for
// real (see flingTranscript for the probe results on touch and scrollbars),
// so it stands in for the whole hardware input pipeline here.
it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-keyboard',
seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }],
}, async (world) => {
await openSeed(
world.page,
INPUTS_FIXTURE,
INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns),
)
await expectBottom(world.page)
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
// Focus rides the last seeded tool row (a tabbable button whose keydown
// handler passes scrolling keys through). End first normalizes the
// focus-driven scrollIntoView back to the floor.
const lastToolRow = world.page.locator(
`[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
)
await lastToolRow.focus()
await world.page.keyboard.press('End')
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
for (let press = 0; press < 3; press += 1) {
await world.page.keyboard.press('PageUp')
await nextPaint(world.page)
}
await backToBottom.waitFor({ timeout: 10_000 })
await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 })
.toBeGreaterThan(100)
await world.page.keyboard.press('End')
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-fling-stream',
replay: [
replayEntry(toolStream()),
replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
],
seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }],
}, async (world) => {
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
const settled = world.scaffold.whenTurnSettled(60_000)
let released = false
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_FLING_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
await expectBottom(world.page)
// Fling away while the turn is mid-flight: the scroll burst alone must
// release bottom ownership, exactly like a wheel scroll would, even
// while streaming keeps re-asserting the floor between frames.
await flingTranscript(world.page, -900)
await backToBottom.waitFor({ timeout: 10_000 })
const awayAnchor = await visibleFlowAnchor(world.page)
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
await writeFile(releasePath, 'release\n')
released = true
await expect.poll(
() => world.events.some(event => event.type === 'tool/result'),
{ timeout: 15_000 },
).toBe(true)
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksBeforeRelease + 5)
await expectSameFlowTop(world.page, awayAnchor)
// Fling back to the floor: re-pin must come from the reader's scroll
// itself, and follow must then own the still-streaming tail. The
// retry loop chases the floor that streaming keeps pushing down.
for (let attempt = 0; attempt < 8; attempt += 1) {
if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break
await flingTranscript(world.page, 1_600)
}
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksAtRepin + 5)
await expectBottom(world.page)
} finally {
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
}
await settled
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expectBottom(world.page)
assertClean(world)
})
}, 180_000)
})

View File

@@ -26,7 +26,7 @@ const MODE = webSnapshotMode()
// The scenario's one drive prompt: elicits one program with a bash sub-call
// and a failing read the program tolerates — the sub-row set the assertions
// (and the PR gif) need. Never asserted against model prose.
// need. Never asserted against model prose.
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
@@ -104,7 +104,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// The parent run_code row wears the code variant with the model-authored
// description as its summary (the PR1 presentCall contract).
// description as its summary (the presentCall contract).
const codeRow = page.locator('[data-variant="code"]').first()
await codeRow.waitFor({ timeout: 10_000 })
// Nested rows are visible WITHOUT any expand interaction, inside the

View File

@@ -8,7 +8,7 @@
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint.
//
// Two layers can only stay together by moving together. They now do: both sit
// Two layers can only stay together by moving together. They do: both sit
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
// tall as the whole draft — so one offset, applied by the browser, moves the
// caret and the words in the same frame. Scrolling the textarea and assigning
@@ -192,7 +192,7 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
* Absolute glyph coordinates are deliberately absent: they depend on font
* metrics and would make the fixture fail on a machine that measures text
* differently — a golden that needs re-recording per platform documents the
* platform, not the change. What is recorded is the cap, the caret-to-glyph
* platform, not the behavior. What is recorded is the cap, the caret-to-glyph
* relation, and which lines are on screen, each a comparison that survives any
* layout keeping the coupling.
* @param top - metrics with the draft scrolled to its start.
@@ -297,10 +297,10 @@ describe('web e2e: composer draft scrolling', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
// A layer that breaks lines somewhere else puts the words under the wrong
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
// draft. The three now share a containing block — the scrollport — so a
// scrollbar that consumes layout space costs them the same width; before,
// only the textarea scrolled, and WebKit reserved gutter space for it alone
// (768 against 776) while chromium and firefox did not.
// draft. All three share a containing block — the scrollport — so a
// scrollbar that consumes layout space costs them the same width; with
// only the textarea scrolling, WebKit reserves gutter space for it alone
// (768 against 776) while chromium and firefox do not.
const metrics = await measureComposer(page)
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
// The mirror decides the box height, so it belongs in the same equality —
@@ -315,7 +315,7 @@ describe('web e2e: composer draft scrolling', () => {
// The reported symptom, isolated. A scroll offset changes and the caret's
// distance to its own glyphs is re-read before the task ends — before any
// `scroll` listener could have run. With the layers on one scrollport the
// browser moved both, so the distance is unchanged; with the glyph layer
// browser moves both, so the distance is unchanged; with the glyph layer
// catching up in a listener it is off by the whole delta until a later
// frame, which is a caret flying away from its text mid-gesture.
const metrics = await measureComposer(page)
@@ -348,9 +348,10 @@ describe('web e2e: composer draft scrolling', () => {
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
// The other way the box moves, and the one that depends on the browser: the
// textarea no longer scrolls, so revealing the caret after an edit is a
// scroll-into-view that has to walk up to the scrollport. Scroll away from
// the caret first, so the edit has somewhere to bring it back from.
// textarea holds no scroll offset of its own, so revealing the caret after
// an edit is a scroll-into-view that has to walk up to the scrollport.
// Scroll away from the caret first, so the edit has somewhere to bring it
// back from.
const input = page.locator('textarea:enabled').first()
await input.press('End')
await input.hover()
@@ -368,9 +369,9 @@ describe('web e2e: composer draft scrolling', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
// The composer suppresses the native paste — the machine owns the draft and
// the undo log — and restores the caret programmatically, which reveals
// nothing on its own: measured in chromium and WebKit, the view stayed
// where it was while the caret sat at the end of the pasted block. The
// restore now scrolls it into view, and this is the case that proves it.
// nothing on its own: in chromium and WebKit the view stays put while the
// caret sits at the end of the pasted block, so the restore scrolls it
// into view; this case pins it.
const input = page.locator('textarea:enabled').first()
await input.fill('one short line')
await input.press('End')

View File

@@ -11,12 +11,12 @@
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
// So the two tabs disagreed by exactly the bar's width for as long as the
// transcript overflowed: the card jumped sideways on every tab switch, and
// inside Chat alone at the moment a growing transcript started to scroll. The
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
// and states the overlay branch as a scroll container on the same axes, so both
// edges are the same edge.
// Without a shared reservation the two tabs disagree by exactly the bar's
// width for as long as the transcript overflows: the card jumps sideways on
// every tab switch, and inside Chat alone at the moment a growing transcript
// starts to scroll. The column reserves the gutter unconditionally
// (`scrollbar-gutter: stable`) and states the overlay branch as a scroll
// container on the same axes, so both edges are the same edge.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
@@ -26,14 +26,14 @@
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree before
// this change as much as after it and every comparison below holds vacuously —
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
// and 0 with the argument dropped. Dropping it is also the faithful
// container's bar consumes no layout width at all, so the two tabs agree with
// and without the reservation and every comparison below holds vacuously —
// measured: the unreserved cascade leaves both tabs' bands at 0 there, against
// 8 and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
// The scenario runs that unreserved cascade in the page — `scrollbar-gutter: auto`
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
// same two tabs through it, which is what keeps the equal rectangles above from
// being explained by a tab switch that never reached the layout. It is the
@@ -64,7 +64,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry',
* that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the reservation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the difference the fix removes rather than only its absence.
* carries the shift the unreserved cascade produces rather than only its absence.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -114,10 +114,10 @@ async function setMeasuredViewport(
}
/**
* The pre-fix cascade, injected into the page: the reservation dropped and the
* overlay branch back to a hidden box. `!important` beats the module rules
* without a rebuild, and the id lets the control be lifted again in the same
* session.
* The unreserved cascade, injected into the page: the reservation dropped and
* the overlay branch forced to a hidden box. `!important` beats the module
* rules without a rebuild, and the id lets the control be lifted again in the
* same session.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
@@ -221,9 +221,9 @@ async function compareTabs(page: Page): Promise<TabComparison> {
}
/**
* Run the pre-fix cascade in the page for one measurement, then lift it.
* Run the unreserved cascade in the page for one measurement, then lift it.
* @param page - the page under test.
* @returns the comparison as the column laid out before this change.
* @returns the comparison as the column lays out without the reservation.
*/
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
@@ -328,7 +328,7 @@ describe('web e2e: input card position across view tabs', () => {
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0)
// The reservation reaches both states, which is the whole change: the same
// The reservation reaches both states, which is the whole point: the same
// band, on a box that scrolls and on one that only holds a view.
expect(comparison.chat.gutter).toBe('stable')
expect(comparison.trajectory.gutter).toBe('stable')
@@ -348,8 +348,8 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its
// width cap, so the pre-fix shift showed up as a centring difference — half
// the band on each edge — rather than as a width change.
// width cap, so the unreserved cascade's shift shows up as a centring
// difference — half the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
@@ -363,7 +363,7 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const comparison = await compareTabs(page)
// The other geometry, and a different failure: below the cap the card takes
// the column's width, so an unreserved gutter changed its WIDTH by the whole
// the column's width, so an unreserved gutter changes its WIDTH by the whole
// band instead of shifting it by half. Asserted against the capped
// measurement rather than against the cap's pixel value, which belongs to
// the stylesheet.
@@ -379,17 +379,17 @@ describe('web e2e: input card position across view tabs', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
// its bar and the Trajectory branch goes back to a hidden box with none, and
// the card moves by half the band on each edge.
// never reached the layout. Under the unreserved cascade the Chat scroller
// keeps its bar and the Trajectory branch becomes a hidden box with none,
// and the card moves by half the band on each edge.
const comparison = await compareTabsWithoutReservation(page)
expect(comparison.chat.gutter).toBe('auto')
expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
// Restoring the sheet restores the fix, so the control cannot leak into the
// remaining measurements.
// Restoring the sheet restores the reservation, so the control cannot leak
// into the remaining measurements.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])

View File

@@ -1,15 +1,15 @@
// Web e2e scenario: the conversation column scrolls on one axis only, as the
// browser actually lays it out. The reported symptom was a horizontal
// scrollbar under the whole center column once the window (or the sidebar
// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the
// column and becoming user-scrollable.
// browser actually lays it out. The hazard: a horizontal scrollbar appears
// under the whole center column once the window (or the sidebar drag) narrows
// it — the hero's decorative backdrop ellipse bleeds past the column and
// becomes user-scrollable.
//
// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
// hero box (ConversationRoot.module.css) so the blur scales with the input
// card. What changed is the scroll container: `[data-conversation-scroll]`
// scrolls vertically, and a box that scrolls in one axis computes the other
// axis's initial `visible` to `auto`, so the bleed came back as a bar. The
// fix states `overflow-x: hidden` there.
// card. The scroll container is where the bar comes from:
// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller
// computes the other axis's initial `visible` to `auto`, so the bleed becomes
// a bar; `overflow-x: hidden` on the scroller prevents it.
//
// Only a real engine reports that pair — the bleed and the resulting scroll
// range — so the scenario sweeps viewport widths that bracket the glow's
@@ -36,7 +36,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-over
* Committed golden of the one-axis relation at every stop. It records
* relations and booleans, never absolute coordinates: the column width follows
* the viewport and the sidebar, and a golden carrying pixels would document the
* platform instead of the change.
* platform instead of the behavior.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -60,17 +60,20 @@ interface ColumnMetrics {
columnWidth: number
/** Resolved `overflow-x` on the conversation scroll container. */
overflowX: string
/** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */
/**
* True when the glow's box reaches past the column's content edge — the
* condition the `overflow-x: hidden` declaration has to survive.
*/
glowBleeds: boolean
/**
* `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
* `auto` both report the same value, because `hidden` clips the bleed rather
* than reflowing it away. Recorded because it is the vacuity guard in
* numbers — it must stay positive at the narrow stops, or the scenario has
* stopped reproducing the situation the fix is for.
* stopped reproducing the situation `overflow-x: hidden` exists for.
*/
bleedRange: number
/** True when the column still scrolls vertically — the axis the fix must not take away. */
/** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */
scrollsVertically: boolean
}
@@ -108,9 +111,10 @@ function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
* This is the one signal that separates the two states, and it is why the
* scenario needs a real engine: `overflow-x: hidden` leaves the box
* programmatically scrollable and leaves `scrollWidth` untouched, so every
* property reading agrees across the fix. Only refusing an actual input event
* differs — measured at the 1200px stop, the shipped column stays at 0 while
* the same page with `overflow-x: auto` forced on lands at its scroll boundary.
* property reading agrees across the two overflow modes. Only refusing an
* actual input event differs — measured at the 1200px stop, the shipped
* column stays at 0 while the same page with `overflow-x: auto` forced on
* lands at its scroll boundary.
* @param page - the page under test.
* @returns `scrollLeft` after one horizontal wheel over the column.
*/
@@ -175,10 +179,10 @@ type ColumnStop = ColumnMetrics & {
* Render the golden body: one line per stop, relations only.
*
* Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
* which the fix pins to 0 by construction. The bleed is recorded as a boolean
* rather than its width, so the golden survives any platform whose column
* lands a pixel off — a fixture that has to be re-recorded per platform
* documents the platform, not the change.
* which the shipped overflow mode pins to 0 by construction. The bleed is
* recorded as a boolean rather than its width, so the golden survives any
* platform whose column lands a pixel off — a fixture that has to be
* re-recorded per platform documents the platform, not the behavior.
* @param stops - the measured stops, in sweep order.
* @returns the golden body, without a trailing newline.
*/
@@ -272,18 +276,19 @@ describe('web e2e: the conversation column scrolls on one axis', () => {
// The reported symptom, stated directly: a horizontal wheel over the
// column moves nothing, at every stop.
expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
// The axis the column is a scroller for must survive the fix.
// The axis the column is a scroller for must survive `overflow-x: hidden`.
expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
}
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('reports the pre-fix state when the axis is opened back up', async () => {
it('scrolls horizontally again once the axis is opened back up (control)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
// The mutation control, run in the page rather than against a second
// build: it restores exactly what the fix changed — the initial `visible`
// that a one-axis scroller computes to `auto` — and shows the same gesture,
// at the same timing, carrying the column to its positive scroll boundary.
// build: it lifts exactly the `overflow-x: hidden` declaration, so the
// initial `visible` that a one-axis scroller computes to `auto` takes
// over, and shows the same gesture, at the same timing, carrying the
// column to its positive scroll boundary.
// Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
// Injected with an id rather than through `addStyleTag`, so the teardown
// below can take the sheet out again by selector: it must not outlive this

View File

@@ -0,0 +1,95 @@
// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
// composer's effort pane — the levels a settings profile declares are exactly
// what the picker offers, and picking one records it with the Agent default.
// Zero model calls: declaring, describing, and switching are settings/llm
// traffic only, so there is no fixture and a stray stream would fail loud.
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 { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
/** Starts the shipped default on this scenario's declared reasoning model. */
const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
// The whole reasoning offer is the profile: key = selectable level, value
// = the wire spelling dispatch would send (`max: ultra` renames; the
// valueless `off` means "supported, send nothing"). The route sets no
// deployment default, so the pane leads with the provider-default entry.
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'acme-gateway': {
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.acme.example/v1',
models: [{
id: 'acme-think',
name: 'Acme Think',
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
}],
},
},
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('offers exactly the declared levels and records the picked one', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
await page.getByRole('menuitem', { name: /推理等级/ }).click()
// Declared levels, nothing else: the provider-default entry (the route
// configures no `reasoning`), then Off/High/Max — minimal, low, medium,
// and xhigh were not declared and must not be offered.
const levels = page.getByRole('menuitemradio')
await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 })
.toEqual(['Default', 'Off', 'High', 'Max'])
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
// Picking a level is the same gesture that saves the default selection, so
// the effort lands in the Agent default Settings section beside provider/model.
await page.getByRole('menuitemradio', { name: 'High' }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).toContain('reasoningEffort: high')
await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('选择模型,当前 Acme Think推理等级 High')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -0,0 +1,8 @@
# The fixture-less web scaffold registers no adapter, so the shipped
# deepseek-official default would be a route nothing serves. This scenario
# starts the default on its own declared reasoning model so the effort pane
# describes that model from the first open.
- id: agent-default-model
config:
provider: acme-gateway
model: acme-think

View File

@@ -0,0 +1,167 @@
// Web e2e scenario: switching models in the composer is how this deployment's
// default is chosen. The gesture writes the shared `agent-default-model` settings section, a
// session created afterwards starts from it, and a session that already logged
// a route keeps deriving from its own log — the tier order the gateway
// resolves on every read.
// Zero model calls: the switch is settings/llm-domain traffic only, so there
// is no fixture and a stray stream would fail loud because the adapter registry is empty. Both
// routes are declared host-side (not through the UI, which has its own
// scenario) through the pi-ai adapter the shipped tree already mounts: a
// fixture-less scaffold registers no adapter at all, so the routes the
// picker offers — and the one the composer must start on — have to come from
// somewhere, and settings profiles are the product's own way to add them.
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 { SessionId } from '@deepseek-ai/dsh-session'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
/** Points the shipped shared Agent default at this scenario's own route. */
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
/** The route this scenario starts on, patched over the shipped default. */
const START_ROUTE = 'origin-gateway'
const START_MODEL = 'origin-large'
/** The route the switch lands on, which then becomes the saved default. */
const ROUTE = 'acme-gateway'
const MODEL = 'acme-large'
describe('web e2e: the composer model switch is the default for later sessions', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
/** Create one session and its agent through the same wire face the browser uses. */
const createSession = async (sessionId: string): Promise<string> => {
const response = await scaffold.ctx.apiProxy.sessions.create({
rpcId: `default-model-create-${sessionId}` as never,
payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
})
if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
return response.result.value.sessionId
}
/** The route the gateway reports for one session, through the real wire face. */
const currentOf = async (sessionId: string): Promise<unknown> => {
const response = await scaffold.ctx.apiProxy.sessions.models({
rpcId: `default-model-${sessionId}` as never,
payload: { sessionId: SessionId(sessionId) },
})
if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
return response.result.value.current
}
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
// Two routes so the picker has somewhere to start and somewhere to go.
// Declared through the settings seam rather than the Models page: this
// scenario is about the composer, and the declaring flow is covered by
// models-settings.e2e.
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
[START_ROUTE]: {
displayName: 'Origin Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.origin.example/v1',
models: [{ id: START_MODEL, name: 'Origin Large' }],
},
[ROUTE]: {
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.acme.example/v1',
models: [{ id: MODEL, name: 'Acme Large' }],
},
},
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The composer's seats only exist once a workspace is connected: without
// one the input is the locked placeholder and no session scope is open.
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('writes the switched model as the default and leaves a logged session alone', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
// A session that has already run a turn, spelled as the fact a turn
// leaves behind: its own logged route.
const loggedId = await createSession('default-model-logged')
scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
header: { config: { provider: START_ROUTE, model: START_MODEL } },
reason: 'initial',
})
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
// The switch is what sets the default: the shared Agent-route settings section
// now names it, beside the provider profiles the Models page writes.
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).toContain('agent-default-model:')
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain(`provider: ${ROUTE}`)
expect(document).toContain(`model: ${MODEL}`)
// A session created after the switch starts from it...
expect(await currentOf(await createSession('default-model-after')))
.toEqual({ provider: ROUTE, model: MODEL })
// ...while the one holding a logged route keeps deriving from its log.
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('goes inert when the route the default names stops being served', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
const box = page.locator('textarea[data-input-phase], textarea').first()
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
// What removing the provider on the Models page leaves behind: the saved
// default still names the route, and nothing serves it any more.
// `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
// stored profile in place.
await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
// The block is an affordance; the refusal is the Host's. A client that
// never disabled anything still cannot start a turn on a dead route.
const refused = await scaffold.ctx.apiProxy.sessions.prompt({
rpcId: 'default-model-refused' as never,
payload: {
sessionId: SessionId(await createSession('default-model-refusal')),
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'hi' }],
},
})
expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
// The way out stays open. Locking the model seat with everything else
// would leave the composer asking for the one thing it prevents.
const seat = page.getByRole('button', { name: /^选择模型/ })
expect(await seat.isEnabled()).toBe(true)
await seat.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio').first().click()
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})

View File

@@ -0,0 +1,8 @@
# The fixture-less web scaffold registers no adapter, so the shipped
# deepseek-official default would be a route nothing serves — which the
# composer refuses to type into. This scenario declares its own
# pi-ai routes and starts the default on one of them.
- id: agent-default-model
config:
provider: origin-gateway
model: origin-large

View File

@@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)

View File

@@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
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 oldText = 'Into the Unknown'
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
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)}`)

View File

@@ -6,9 +6,10 @@
// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
// gateway), reload replays everything from the log (zero further model
// calls), and the theme scenario proves the shipped dark palette actually
// cascades: attribute -> alias token flip -> painted surface change. Per the
// lane's scope ruling there is no theme/layout golden (aria is color-blind);
// the hero's waiting state gets the one golden here.
// cascades: attribute -> alias token flip -> painted surface change. No
// theme/layout golden: aria snapshots are color-blind (lane scope: the
// browser-e2e-lane Agent Note); the hero's waiting state gets the one golden
// here.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -112,8 +113,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
// The golden encodes an empty composer, and the button arriving does not
// mean the submitted text is gone yet: under load the capture caught a
// textbox still holding `/plan`.
// mean the submitted text is gone yet: under load the capture can catch
// a textbox still holding `/plan`.
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
@@ -159,7 +160,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
// The blank frame renders the hero, not the resident composer: the
// headline plus the guidance placeholder are the empty state's anchors.
await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
if (MODE !== 'record') {
@@ -232,7 +233,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
// This scenario pins the ThemeService's DOM contract seam directly (the
// This scenario pins the ThemeService's DOM contract directly (the
// body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
// user gesture above it (Settings -> Appearance cubes) is owned by
// settings-chrome.e2e.ts. Driving the attribute here keeps the cascade

View File

@@ -1,6 +1,6 @@
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
// and transient-retry recovery, all through the real composition and wire.
// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
// The model adapter is dsh-llm-replay with override sidecars: `hang` (+ a
// readyFile marker) makes mid-stream cancel deterministic by construction,
// `throw` entries express provider failures by stable code, and `{ patches }`
// augmentation injects a transient throw before the recorded success so

View File

@@ -126,7 +126,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', { name: 'Select model', exact: true })
await page.getByRole('button', { name: /^Select model, current/ })
.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.

View File

@@ -7,7 +7,7 @@
// provider status. The customized-settings fold writes the curated
// reasoning field as a merge patch. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud on the open seam. The provider under test is
// stray stream would fail loud because the adapter registry is empty. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
// never shadow the derived reference. The deletion dialog distinguishes a
// reference-free profile from a page-managed key before the credential and
@@ -27,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
@@ -78,6 +79,26 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
}, 60_000)
it('refuses a key no HTTP header can carry before anything is written', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
const dialog = page.getByRole('dialog', { name: '设置' })
const key = dialog.getByLabel('API 密钥')
const save = dialog.getByRole('button', { name: '保存', exact: true })
// A key no HTTP header can carry would save cleanly and fail the first
// turn with a ByteString TypeError; the form names the offending field
// instead.
await key.fill('sk-\u{1F600}minimax')
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
// Clearing it restores submit: an empty field means "keep what is stored",
// never a refusal, or editing any other setting would demand the key.
await key.fill('')
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
}, 60_000)
it('saves a blank key as a reference-free provider-native profile', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
const dialog = page.getByRole('dialog', { name: '设置' })
@@ -114,7 +135,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The profile lands in settings.yaml with only the derived reference, the
// key value lands in the harness home's .env, the dormant route
// key value lands in the harness home's .credentials.yaml, the dormant route
// registers, and the topology frame invalidates the page into the row.
await expect.poll(
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
@@ -126,11 +147,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
expect(document).not.toContain('sk-e2e-minimax')
const credentialFile = join(scaffold.harnessHome, '.env')
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
await expect.poll(
async () => readFile(credentialFile, 'utf8').catch(() => ''),
{ timeout: 10_000 },
).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
expect(await page.content()).not.toContain('sk-e2e-minimax')
}, 60_000)
@@ -139,22 +160,55 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByText('自定义设置').click()
const effort = dialog.getByLabel('推理强度')
await effort.waitFor({ timeout: 10_000 })
await effort.selectOption('high')
const url = dialog.getByLabel('API 地址')
await url.waitFor({ timeout: 10_000 })
await url.fill('https://gateway.minimax.example/v1')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The editor closes back to the row; the fold's write merged into the
// stored profile beside the reference.
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('reasoning: high')
expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('declares a route the adapter does not ship', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
const dialog = page.getByRole('dialog', { name: '设置' })
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
await declare.click()
await dialog.getByLabel('Provider ID').fill('acme-gateway')
await dialog.getByLabel('显示名称').fill('Acme Gateway')
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
// No reasoning effort on a provider card at all: effort is a per-model
// capability, the models under one provider disagree about it, and a
// switch in the composer already records provider+model+effort together.
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
await dialog.getByRole('button', { name: '添加模型' }).click()
await dialog.getByLabel('模型 ID 1').fill('acme-large')
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('acme-gateway:')
// The tag follows the adapter's installed catalog: this route is in no
// catalog, while minimax-cn is — even though both now have profiles.
const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first()
await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1)
expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('confirms an identified provider deletion before removing its profile and key', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
@@ -177,7 +231,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
.not.toContain('MINIMAX_CN_API_KEY')
await expect.poll(
async () => page.getByRole('dialog', { name: '删除 minimax-cn' }).count(),
@@ -189,7 +243,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
'empty.expected.md', 'native-delete.expected.md',
])
})
})

View File

@@ -331,7 +331,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
// Real layout, not jsdom's stub (which computes no geometry at all):
// squeeze the output pane below its content width and the line must keep
// its single row and overflow sideways instead of folding. Soft-wrapping
// here is what shredded the column alignment this card exists to hold.
// here shreds the column alignment this card exists to hold.
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
const pane = node as HTMLElement
const row = pane.querySelector<HTMLElement>('[class*="_line_"]')

View File

@@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await settings.getByRole('button', { name: '保存', exact: true }).click()
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
expect((await page.content()).includes(secret)).toBe(false)
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
expect(browserConsole.some(line => line.includes(secret))).toBe(false)

View File

@@ -1,4 +1,4 @@
# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the
# Loader overlay for the real-host smoke (`dsh web --patch`): pin the
# in-browser directory picker. The shipped row is `-auto`, which resolves to
# the native OS chooser on a loopback bind with a local display — an
# interaction a Playwright page cannot drive, so the resolved backend would

View File

@@ -0,0 +1,162 @@
// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds
// a built write turn (zero model calls) whose closing message names the written
// file three ways: by unique basename (links), ambiguously (stays inert), and
// as a file the turn never touched (stays inert). Package tests cover the
// resolver in isolation; only the assembled application shows a real write's
// locations reaching the prose as an opener. The click itself is not driven
// here: it hands the path to the Host's opener, which would launch a real
// application on the machine running the suite (the produced-files restraint).
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SEED_ID = 'produced-file-mentions-web-e2e'
const DONE = 'FILE_MENTION_DONE'
/** One-part text content for a built message. */
function text(value: string): { type: 'text'; text: string }[] {
return [{ type: 'text', text: value }]
}
/** The files the built turn writes; `notes.md` is named in prose but never written. */
const WRITES = ['site/report.html', 'a/style.css', 'b/style.css']
/** Build a settled write turn whose closing prose mentions files in inline code. */
function mentionFixture(): string {
const session = Session.create(SessionId('produced-file-mentions-source'))
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Produced file mentions',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
const calls = WRITES.map((path, index) => ({
path,
callId: CallId(`file-mention-${String(index)}`),
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
}))
session.append('assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: calls.map(call => ({
type: 'tool-call' as const,
id: call.callId,
name: 'write',
arguments: call.args,
})),
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
for (const call of calls) {
const source = session.append('tool/call', {
turn: 1,
step: 1,
callId: call.callId,
name: 'write',
arguments: call.args,
})
session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: call.callId,
content: text(`Created ${call.path}`),
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
turn: 1,
step: 2,
message: createAssistantMessage({
content: [{
type: 'text',
text: [
'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
'',
DONE,
].join('\n'),
}],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify({
...event,
time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
describe('web e2e: inline-code mentions of produced files', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, mentionFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Exactly one prose mention links: `report.html` resolves to the written
// path; the shared `style.css` basename and unwritten `notes.md` stay code.
const mentions = page.locator('[class*="markdown"] code button')
await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
expect(await mentions.first().innerText()).toBe('report.html')
expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
// The turn still ends with its produced-files row (all three writes).
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
})

View File

@@ -0,0 +1,76 @@
// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds
// a recorded write turn (zero model calls). Package tests cover the derivation
// in isolation, but only the assembled application shows that a turn's writes
// reach the transcript as an openable row (docs/testing.md snapshot rule). The
// click itself is not driven here: it hands the path to the Host's opener,
// which would launch a real application on the machine running the suite.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
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 {
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a
// file, not a new recording (the message-actions borrowing pattern).
const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'produced-files-web-e2e'
/** The file the borrowed recording's write tool produces. */
const PRODUCED = 'policy-neutral.txt'
describe('web e2e: a finished turn ends with the files it produced', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The seeded Session's cwd is the scaffold workspace; the recording's own
// nested directory is created too, so its paths stay resolvable.
await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true })
await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n')
const raw = await readFile(SEED, 'utf8')
expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED)
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
// The row the turn ends with — derived from the write call's locations,
// not from whatever the closing message happened to say.
const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first()
await chip.waitFor({ timeout: 15_000 })
expect(await chip.innerText()).toBe(PRODUCED)
// The full path stays reachable for a reader who wants to copy it.
expect(await chip.getAttribute('title')).toContain(PRODUCED)
// A turn's produced files are labelled, not left as bare chips.
expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
})

View File

@@ -0,0 +1,27 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { expect, it } from 'vitest'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<link rel="manifest" href="/manifest.webmanifest" />')
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
expect(manifest).toEqual({
id: '/',
name: 'DeepSeek Harness',
short_name: 'DSH',
start_url: '/',
scope: '/',
display: 'fullscreen',
icons: [{
src: '/favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
}],
})
})

View File

@@ -2,8 +2,8 @@
// 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
// pill — not the generic console-fenced card the pwsh presenter used to
// emit. The seed is authored, not recorded: its header line carries no `cwd`
// 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
// substituted into the header would not round-trip through its JSON parse),
// and no event references the workspace, so the lane replays on any host

View File

@@ -1,6 +1,6 @@
// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo
// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless)
// in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless)
// or the live adapter (record). Drive steps run in every mode and wait only
// on generic completion (whenTurnSettled — never model-content selectors, so
// record cannot hang on a live model answering differently); assertion steps

View File

@@ -32,7 +32,7 @@ import { expect } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
import {
addHarnessSourceSection,
assertEntriesLoaded,
@@ -45,6 +45,10 @@ import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type {
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import SessionStore, {
@@ -93,6 +97,46 @@ const REPLAY_PROVIDERS = [{
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
}]
/**
* The routes a shipped composition always has, with no ability to stream.
* A fixture-less keyless scenario issues no model calls, but its tree must
* still answer `listProviders()` — surfaces legitimately gate on whether any
* adapter serves a session's route, and an empty registry is a test artifact,
* not a product state.
*/
class RouteOnlyAdapter extends LlmAdapter {
constructor(private readonly providers: typeof REPLAY_PROVIDERS) {
super()
}
override providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider }
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? [])
.map(model => ({ provider, id: model.id, name: model.name })))
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const listed = this.providers.find(entry => entry.id === provider)?.models
.find(entry => entry.id === model)
return Promise.resolve({
provider,
id: model,
name: listed?.name ?? model,
...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow },
})
}
override async *stream(): AsyncIterable<StreamChunk> {
throw new Error(
'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture'
+ ' — pass replayFixture, or keep the scenario free of model calls',
)
}
}
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
if (contextWindow === undefined) return REPLAY_PROVIDERS
return REPLAY_PROVIDERS.map(provider => ({
@@ -107,7 +151,7 @@ export interface WebScaffold {
mode: WebSnapshotMode
/** Browser-facing origin for the bound test server. */
baseUrl: string
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
/** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */
ctx: Context
/** Temp project directory sessions run in (bash/fs tool cwd). */
workspaceCwd: string
@@ -390,6 +434,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
// No fixture and no shipped adapter would leave the tree with ZERO
// provider routes — a state no product composition has, and one the
// composer refuses to type into. Register the same routes
// a fixture would, with streaming that still fails loud: the scenario
// issues no model calls, and one that slipped in must not pass quietly.
ctx.effect(() => ctx.llm.registerAdapter(
replayProviders(options.replayContextWindow).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
), 'web e2e scaffold: route-only adapter')
}
} catch (error) {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
@@ -474,11 +528,14 @@ function rawSessionLog(session: Session): string {
export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise<void> {
const agent = scaffold.ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
const fresh = scrubRequestHeaders(rawSessionLog(agent.session))
.split(sessionId).join('{{sessionId}}')
.split(scaffold.workspaceCwd).join('{{cwd}}')
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
await writeFile(fixturePath, tokenized)
const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : ''
const stable = stabilizeFixtureMessageIds([fresh], [existing])[0]
if (stable === undefined) throw new Error('record harvest: no stabilized fixture')
await writeFile(fixturePath, stable)
}
/**
@@ -563,8 +620,9 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
}
/**
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and
* decode-throughput volatility collapse to stable tokens.
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration,
* decode-throughput, and path-sensitive compaction estimates collapse to
* stable tokens.
*
* Throughput needs a token for the same reason durations do, and no fixture
* can supply one: the figure divides a replayed step's output tokens by the
@@ -591,6 +649,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
duration => duration.startsWith('约') ? duration : '{{duration}}',
)
.replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
// Seeded compaction prices realized file paths, whose length differs
// 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.
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')

View File

@@ -1,8 +1,8 @@
// @vitest-environment jsdom
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
// Assembled search-card snapshot: boots the real built workspace client bundles
// through AppWebEntry's ModuleLoader path against the keyless
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
// 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
// that a broken SearchRow registration or a dropped card would fail — the
@@ -51,7 +51,7 @@ describe('assembled search card', () => {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
// Wait for chat content to reach the fixture's later turns (the bash sample
// is turn 65, the grep card turn 66).
// is turn 66, the grep card turn 67).
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })

View File

@@ -4,8 +4,9 @@
// 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 surface: an Access-chip pick runs `/permission` on the
// host, so the settled row's copy has a golden here. The seed is a recorded
// 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
// live through the composer (real read tool against seeded workspace files)
@@ -39,18 +40,18 @@ const SEED_ID = 'seeded-history-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
/**
* Append a complete, valid compaction transaction over the recorded turn's own
* surface. The recording stays model-authentic and reusable; replay adds this
* deterministic condition before seeding it cold, so the scenario pins the bug
* this change fixes — a landed compaction must not erase history the reader
* already saw — through the real host and the real browser.
* Append a complete manual `/compact` lifecycle and valid compaction transaction
* over the recorded turn's own surface. The recording stays model-authentic and
* reusable; replay adds this deterministic condition before seeding it cold, so
* the scenario pins both the log-preserving marker and its single-card command
* presentation through the real host and browser.
* @param raw - the seed fixture text, already realized (placeholder-free) so
* the shadow price below is computed from the exact strings the host folds.
* @param meter - the composed token meter; the appended `compact/summary`'s
* shadow price must be the exact heuristic price of the shadowed nodes, the
* way compact-basic derives it, because the token-meter projections subtract
* it verbatim.
* @returns the fixture with a compacted turn appended.
* @returns the fixture with a manual compaction lifecycle appended.
*/
function withCompaction(raw: string, meter: TokenMeterService): string {
const lines = raw.trimEnd().split('\n')
@@ -73,28 +74,32 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
if (first === undefined || last === undefined || tail === undefined) {
throw new Error('seeded-history compaction requires a non-empty closed surface')
}
// The transaction opens the turn after the recording's last closed one; read
// it from the fixture so a re-recording with a different turn count stays
// valid instead of appending a duplicate turn number.
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
if (typeof lastTurn !== 'number') {
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
}
const turn = lastTurn + 1
let seq = tail.seq + 1
let time = tail.time + 1
/**
* Append one event at the next seq/time.
* @param event - the event body, without seq/time.
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
* @returns the assigned seq, so later `sourceEventSeqs` cite the pushed event directly.
*/
const at = (event: Record<string, unknown>): number => {
const taken = seq++
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn } })
const startSeq = at({ type: 'compact/start', data: { turn } })
const commandId = 'cmd-seeded-manual-compact'
const compactionId = 'compact-seeded-manual-compact'
at({
type: 'command/run',
data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
})
const startSeq = at({
type: 'compact/start',
data: { compactionId, sourceCommandId: commandId, turn: null },
})
// 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
@@ -123,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
const summarySeq = at({
type: 'compact/summary',
data: {
compactionId,
sourceCommandId: commandId,
summary: [{
type: 'text',
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
@@ -141,13 +148,31 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
type: 'text',
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'compact' },
source: {
kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
},
},
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn } })
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
at({
type: 'compact/end',
data: { compactionId, sourceCommandId: commandId, turn: null },
})
at({
type: 'command/done',
data: {
commandId,
kind: 'success',
text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`,
sourceEventSeq: summarySeq,
},
})
// The persistence seed helper requires a terminal turn/end. Keep the manual
// command standalone, then add a closed zero-step fixture boundary after it.
const closureTurn = lastTurn + 1
at({ type: 'turn/start', data: { turn: closureTurn } })
at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } })
return `${lines.join('\n')}\n`
}
@@ -239,14 +264,18 @@ describe('web e2e: seeded history renders through cold resume', () => {
await sessionRow.click()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
timeout: 10_000,
}).toBe(1)
expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
// Tool cards render from logged tool/call + tool/result alone (views are
// host-recomputed per page; the generic card is the documented default).
const toolRows = page.locator('[data-variant], [data-sample]')
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
// The bug this fixes: the compaction shadowed the whole recorded surface on
// the model side, and the prompt and full tool output are still on screen.
// The pinned hazard: compaction shadows the surface on the model side
// only — the prompt and full tool output must stay on screen.
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
@@ -280,10 +309,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the routed id, but without an
// advertised catalog row the selector prompts for a listed replacement.
await page.getByRole('button', { name: 'Select model', exact: true })
// This scenario issues zero model calls — the scaffold's route-only
// adapter serves the catalog and refuses to stream — so history restores
// the routed id and the seat resolves it against an advertised row.
await page.getByRole('button', { name: /^Select model, current/ })
.waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
@@ -363,7 +392,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
const marker = page.getByRole('button', { name: /Context compacted/ })
const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
await marker.waitFor({ timeout: 10_000 })
expect(await marker.getAttribute('aria-expanded')).toBe('false')
await marker.click()

View File

@@ -1,7 +1,8 @@
// 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
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// -> 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.
@@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => {
it('flips the theme through the Appearance cubes and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
await page.evaluate(() => ({
interface ThemeState {
attr: boolean
background: string
stored: string | null
themeColor: string | null
themeColorCount: number
token: string
}
const readState = async (): Promise<ThemeState> => await page.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'),
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
background: computed.backgroundColor,
stored: localStorage.getItem('dsh.theme'),
}))
themeColor: metas[0]?.content ?? null,
themeColorCount: metas.length,
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
}
})
const expectThemeColorSynchronized = (state: ThemeState): void => {
expect(state.themeColorCount).toBe(1)
expect(state.background).not.toBe('rgba(0, 0, 0, 0)')
expect(state.themeColor).toBe(state.background)
}
// Pin the OS scheme to light so the default `system` preference resolves
// light and the dark flip below is unambiguously the gesture's doing.
await page.emulateMedia({ colorScheme: 'light' })
const light = await readState()
expect(light.attr).toBe(false)
expectThemeColorSynchronized(light)
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
@@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(dark.attr).toBe(true)
expect(dark.stored).toBe('dark')
expect(dark.token).not.toBe(light.token)
expectThemeColorSynchronized(dark)
await page.keyboard.press('Escape')
// Reload: the preference survives boot (restore + presenter initial apply).
@@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => {
const reloaded = await readState()
expect(reloaded.attr).toBe(true)
expect(reloaded.stored).toBe('dark')
expectThemeColorSynchronized(reloaded)
// `system` follows the emulated OS scheme (dark stays dark, light clears).
await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => {
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
expectThemeColorSynchronized(await readState())
// Restore for the specs that follow: light preference beats the emulated
// dark OS scheme, leaving the shared page in the light default.
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
@@ -244,7 +270,7 @@ describe('web e2e: settings modal and General preferences', () => {
await selector.click()
await page.getByRole('menuitem', { name: 'English' }).click()
// The settings-owned copy re-registers localized: dialog title, nav,
// Appearance labels. (Only the settings namespaces are localized today
// Appearance labels. (Only the settings namespaces are localized —
// the rest of the app's copy is intentionally out of this row's scope.)
const enDialog = page.getByRole('dialog', { name: 'Settings' })
await enDialog.waitFor({ timeout: 10_000 })

View File

@@ -6,11 +6,13 @@
import { tmpdir } from 'node:os'
import { afterEach, expect, it } from 'vitest'
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.
import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-permission'
import type {} from '@deepseek-ai/dsh-commands'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/**
@@ -28,6 +30,7 @@ const EXPECTED_TOOLS = [
'edit',
'exit_plan_mode',
'get_goal',
'interrupt_agent',
'list_agents',
'ralph',
'read',
@@ -79,4 +82,19 @@ it('assembles the shipped Web catalog with the confined access default', async (
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
expect(scaffold.ctx.approval.config.policy).toBe('ask')
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
const handle = await scaffold.ctx.agents.create({
sessionId: SessionId('shipped-command-catalog'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
try {
expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({
name: 'feedback',
description: 'record feedback about this session',
input: { hint: '<text>' },
})
} finally {
await handle.dispose()
}
}, 120_000)

View File

@@ -1,5 +1,5 @@
// Web e2e scenario: the sidebar session list's scrollbar as the browser
// actually lays it out — the observable half of the themed-scrollbar change
// actually lays it out — the observable half of the themed scrollbars
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
@@ -17,8 +17,8 @@
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
// content, consuming no layout width unless something reserves space. That is
// the mode in which the reported symptom exists at all, so this environment
// reproduces it rather than merely approximating it — measured against clean
// master, where the list's band is 0 and the bar covers 7px of the relative
// reproduces it rather than merely approximating it — without either
// declaration the list's band is 0 and the bar covers 7px of the relative
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
// the bar and nothing can be covered; a headed run under xvfb behaves that way
// and cannot show the symptom.)
@@ -40,9 +40,8 @@
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
// so it abuts the timestamp without covering it; `band` catches that case.
// Removing both — the actual master state — is what produces the reported
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
// the other assertions in its test silenced.
// Removing both is what produces the reported overlap, and `timeCoveredBy`
// measures it at 7.
//
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
// to `transparent` while the pointer is outside the column), so every
@@ -135,7 +134,8 @@ interface ListMetrics {
/**
* Measure the sidebar list in the page.
* @param page - the page under test.
* @returns the list's resolved scrollbar style and the geometry the fix changes.
* @returns the list's resolved scrollbar style and the geometry the
* scrollbar-gutter/thin-scrollbar declarations shape.
*/
function measureList(page: Page): Promise<ListMetrics> {
return page.evaluate(() => {
@@ -201,8 +201,8 @@ function measureList(page: Page): Promise<ListMetrics> {
// The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
// what this chromium paints, measured against master where the rule is
// absent. Taking the UA width as the fallback is what keeps the assertion
// what this chromium paints, measured with the rule absent. Taking the
// UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the
// state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
@@ -261,13 +261,14 @@ async function measurePalette(page: Page): Promise<PaletteMetrics> {
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the fix establishes.
* palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
* declarations establish.
*
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
* committing them would make the golden fail on a machine whose fonts measure
* differently — a fixture that has to be re-recorded per platform documents the
* platform, not the change. What is recorded instead is the band, the overlap,
* platform, not the behavior. What is recorded instead is the band, the overlap,
* and the two orderings, each of which is a difference or a comparison and so
* survives any layout that keeps the reservation.
* @param light - metrics measured under the light palette.
@@ -417,12 +418,13 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(metrics.scrollbarEdgeOffset).toBe(2)
expect(metrics.rowEdgeInset).toBe(12)
// The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
// covered part. Unlike the client-edge comparison below it does not go
// vacuous under an overlay scrollbar, because it measures against the bar's
// own width rather than against a content edge the overlay bar does not
// move. It is not a replacement for the band assertion above; see the file
// header for which regression each one catches.
// lies under the bar. Without either declaration it measures 7 — the `h`
// of `1h` is the covered part. Unlike the client-edge comparison below it
// does not go vacuous under an overlay scrollbar, because it measures
// against the bar's own width rather than against a content edge the
// overlay bar does not move. It is not a replacement for the band
// assertion above; see the file header for which regression each one
// catches.
expect(metrics.timeCoveredBy).toBe(0)
// Corollaries of the reservation, kept because they pin where the band sits
// rather than only that it exists: the time ends inside the content area,
@@ -451,7 +453,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(quiet.band).toBeGreaterThan(0)
expect(quiet.timeCoveredBy).toBe(0)
// Scrolling without a pointer — what a keyboard or a touch drag does —
// leaves the column quiet. This is the change's one deliberate loss, and
// leaves the column quiet. This is the one deliberate loss, and
// it is pinned here rather than only described, so making a scroll
// re-reveal the bar has to be a decision rather than a side effect.
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })

View File

@@ -0,0 +1,156 @@
import { mkdir } 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 { AgentHandle } from '@deepseek-ai/dsh-agent'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionId as SessionIdValue } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-workspace'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-subagent-activity', import.meta.url))
const RUNNING_OWNER_EXPECTED = join(SNAPSHOT_DIR, 'owner-running.expected.md')
const MODE = webSnapshotMode()
const HOLD_PROVIDER = 'web-test-hold'
const HOLD_MODEL = 'hold'
/** Model stub that completes the owner turn, then holds its delegated child open. */
class StagedAdapter extends LlmAdapter {
activeCalls = 0
private calls = 0
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (this.calls === 0) {
this.calls += 1
yield { type: 'finish', reason: { kind: 'stop' } }
return
}
this.calls += 1
const signal = options.signal
if (signal === undefined) throw new Error('staged Web adapter requires a turn signal')
this.activeCalls += 1
try {
await new Promise<never>((_resolve, reject) => {
const abort = (): void => {
reject(signal.reason instanceof Error ? signal.reason : new Error('holding Web adapter aborted'))
}
if (signal.aborted) abort()
else signal.addEventListener('abort', abort, { once: true })
})
} finally {
this.activeCalls -= 1
}
}
}
async function waitForRunningChild(
scaffold: WebScaffold,
adapter: StagedAdapter,
childId: SessionIdValue,
): Promise<void> {
const deadline = Date.now() + 10_000
while (adapter.activeCalls !== 1 || scaffold.ctx.agents.get(childId)?.status !== 'running') {
if (Date.now() >= deadline) throw new Error('held child did not enter its running model call')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
describe('web e2e: sidebar subagent activity', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let parentHandle: AgentHandle
let childId: SessionIdValue
let adapter: StagedAdapter
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold()
adapter = new StagedAdapter()
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([HOLD_PROVIDER], adapter),
'sidebar subagent activity staged adapter',
)
const cwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(cwd)
parentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('sidebar-activity-owner'),
meta: { cwd },
agentOptions: { provider: HOLD_PROVIDER, model: HOLD_MODEL },
})
parentHandle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'Delegate a background task.' }],
source: { kind: 'user' },
}))
await parentHandle.agent.whenIdle()
const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn',
label: 'sidebar activity child',
signal: new AbortController().signal,
request: {
prompt: [{ type: 'text', text: 'Hold this delegated task open.' }],
parent: parentHandle.agent,
},
})
childId = started.childId
await waitForRunningChild(scaffold, adapter, childId)
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)
const workspace = await scaffold.ctx.workspace.resolveByPath(cwd)
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
await workspace.attachSession(parentHandle.agent.session.id)
}, 60_000)
afterAll(async () => {
const failures: unknown[] = []
const child = childId === undefined ? undefined : scaffold?.ctx.agents.get(childId)
if (child !== undefined) {
child.cancel({ kind: 'user' })
await child.whenIdle().catch((error: unknown) => failures.push(error))
}
await browser?.close().catch((error: unknown) => failures.push(error))
await parentHandle?.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, 'sidebar subagent activity teardown failed')
})
it('pins a running descendant on its visible idle owner row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-subagent-activity'))
const sidebar = page.getByRole('tree', { name: 'Sessions' })
const ownerRow = sidebar.getByRole('treeitem', { name: /1 subagent running Delegate a background task/ })
await ownerRow.waitFor({ timeout: 10_000 })
expect(parentHandle.agent.status).toBe('idle')
await compareOrRefreshGolden(
RUNNING_OWNER_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
MODE,
)
expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1)
await ownerRow.click()
const runningTrigger = page.getByRole('button', { name: '1 subagent running' })
await runningTrigger.waitFor({ timeout: 10_000 })
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -1,5 +1,6 @@
// Web e2e scenario: the real host filters skill.list to the model-and-user
// intersection before the browser slash source renders candidates. A real
// Web e2e scenario: the real host serves every user-invocable skill to the
// browser slash source — user-only (disable-model-invocation) entries appear
// with their marker while user-disabled quadrants stay hidden. A real
// chromium connects a fresh workspace seeded with all four policy quadrants;
// no model call is issued, so a stray stream fails loud on the open LLM seam.
import { mkdir, writeFile } from 'node:fs/promises'
@@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
await scaffold?.close()
})
it('renders only the model-and-user intersection in slash candidates', async () => {
it('renders every user-invocable skill and marks the user-only entry', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
const input = page.locator('textarea').first()
await input.fill('/policy')
@@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => {
{ timeout: 10_000 },
).toBe(1)
// The user-only quadrant is invocable here — its only entry point — and
// wears the user-only marker; both user-disabled quadrants stay hidden.
expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1)
expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)

View File

@@ -0,0 +1,80 @@
// Web e2e scenario: the real skill-load recording, seeded cold through the
// persistence seam, renders through ui-skill's keyed toolview without a model
// call. The disclosure proves replay-stable naming and exact durable output.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/skill-load/session.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'skill-tool-row-web-e2e'
const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.'
describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
const fixture = await readFile(FIXTURE, 'utf8')
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
scaffold = await launchWebScaffold({})
await seedSession(scaffold, fixture, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('expands the loaded skill to its exact recorded instructions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row'))
const call = page.locator('[data-tool="skill"]')
const row = call.getByRole('button', { name: 'Skill snapshot-skill' })
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
await call.getByText('Instructions', { exact: true }).waitFor()
const output = call.locator('pre')
await output.waitFor()
expect(await output.textContent()).toContain('<skill_content name="snapshot-skill">')
expect(await output.textContent()).toContain('Follow these snapshot-only instructions.')
expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px')
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -0,0 +1,150 @@
// Web e2e scenario: a user invokes a disable-model-invocation skill through
// the composer (issue #1470). The entered `/name args` line claims into
// skill.invoke: the real host forwards the gesture as an ordinary user
// prompt, injects the rendered body as instructions context named after the
// skill, and starts a turn answered by the replay adapter. The transcript shows
// the gesture bubble, the collapsed context-injection row, and the reply.
import { mkdir, mkdtemp, 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 type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const SKILL_NAME = 'user-invoke-demo'
const ARGS_TEXT = 'and confirm the fixture wiring'
const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
async function seedUserOnlySkill(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 user-explicit invocation of a model-hidden skill',
'disable-model-invocation: true',
'---',
'',
'Reply with the fixture acknowledgement line.',
'',
].join('\n'))
}
const REPLAY: ReplayOverrideDoc = [{
kind: 'chunks',
chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: REPLY },
{ type: 'block-end', index: 0, block: { type: 'text', text: REPLY } },
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } },
{ type: 'finish', reason: { kind: 'stop' } },
],
}]
describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let replayDir: string
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(REPLAY))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
// Paced replay keeps the timing-derived chrome (TTFT / tok/s) present
// deterministically; instant playback races it in and out of the golden.
paceMs: 10,
})
await seedUserOnlySkill(scaffold.workspaceCwd)
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 () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (replayDir !== undefined) {
await rm(replayDir, { 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, 'skill-user-invoke e2e cleanup failed')
})
it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
const composer = page.locator('textarea:enabled').last()
await composer.waitFor({ timeout: 15_000 })
// The menu lists the user-only skill (its only entry point) before enter.
await composer.fill(`/${SKILL_NAME}`)
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await expect.poll(
() => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(),
{ timeout: 10_000 },
).toBe(1)
const settled = scaffold.whenTurnSettled()
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
await composer.press('Enter')
// The gesture stays an ordinary user bubble (decorated /name token plus
// the trailing text), ahead of the injected context.
const bubble = page.locator('[data-ref-chip="skill"]').first()
await bubble.waitFor({ timeout: 15_000 })
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
// The rendered body arrives as a context-injection row named after the
// skill; expanding it reveals the canonical <skill_content> block, and
// the user's text is NOT folded into it.
const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
await injectionRow.waitFor({ timeout: 15_000 })
await injectionRow.click()
const injectionBody = page
.locator('[data-context-injection-body]')
.filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
await injectionBody.waitFor({ timeout: 10_000 })
const injected = await injectionBody.textContent()
expect(injected).toContain('Reply with the fixture acknowledgement line.')
expect(injected).not.toContain(ARGS_TEXT)
await injectionRow.click()
// The injection started a turn; the replay adapter answers it.
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
await settled
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -1,4 +1,4 @@
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
// Real-host smoke: spawn `dsh web` with a real key, walk the full flow
// list in a real chromium, screenshot every screen into .artifacts/ for the
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
// convention); vitest.web.config.ts loads the repo-root .env before this file
@@ -11,8 +11,9 @@
// (frame/handle) rides local names that survive hashing as suffixes; prefer
// data-* for anything new.
//
// Flow order matters: chat rounds first (5 depends on 3's session), geometry
// and theme after, reload recovery last. Tests run sequentially in-file.
// Flow order matters: chat rounds first (the bash round reuses the first
// send's session), geometry and theme after, reload recovery last. Tests run
// sequentially in-file.
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
@@ -119,7 +120,7 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker
}).toBe(true)
}
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
/** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
async function screen(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
}
@@ -465,7 +466,7 @@ describe('dsh web keyless CLI smoke', () => {
})
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key)', () => {
let child: ChildProcess
let sessionsDir: string
let baseUrl: string
@@ -518,7 +519,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
})
it('1 cold start: loading page settles into the three-column frame', async () => {
it('cold start: loading page settles into the three-column frame', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
@@ -527,7 +528,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '01-cold-start')
})
it('2+3 empty-state first send completes a real model round', async () => {
it('empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
// This scenario spawns its own server against a fresh $DSH_HOME, so the
// first-run welcome notice is unacknowledged and its overlay owns pointer
@@ -590,7 +591,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '07-back-to-chat')
})
it('5 bash differential rendering: tool row click leaves the default details column closed', async () => {
it('bash differential rendering: tool row click leaves the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
const input = page.locator('textarea').first()
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
@@ -604,12 +605,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0)
await toolRow.click()
// Tool rows no longer drive layout.openDetails; the default column stays closed.
// Tool rows do not drive layout.openDetails; the default column stays closed.
expect(await detailsTrack(page)).toBe(0)
await screen(page, '09-details-closed')
}, 150_000)
it('6 sidebar drag widens the column and resets across reload', async () => {
it('sidebar drag widens the column and resets across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
const before = await firstTrack(page)
const handle = page.locator('[class*="handle"]').first()
@@ -627,10 +628,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
expect(await firstTrack(page)).toBe(before)
})
it('7 dark mode: the body attribute cascades the token sheets', async () => {
it('dark mode: the body attribute cascades the token sheets', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-dark'))
// theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
// in P-I, so the acceptance drives the documented mechanism directly.
// The body attribute is the documented cascade mechanism; the Settings
// gesture is owned by settings-chrome.e2e.ts — drive the attribute
// directly here.
const dark = await page.evaluate(() => {
document.body.setAttribute('data-ds-dark-theme', '')
return getComputedStyle(document.body).backgroundColor
@@ -643,7 +645,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
expect(dark).not.toBe(light)
})
it('8 reload recovery: history replays after a fresh boot', async () => {
it('reload recovery: history replays after a fresh boot', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-reload'))
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -23,8 +23,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok

View File

@@ -14,10 +14,10 @@
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"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,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}

View File

@@ -0,0 +1,7 @@
- menu "模型与推理等级":
- menuitemradio "Default" [checked]:
- text: Default
- img
- menuitemradio "Off"
- menuitemradio "High"
- menuitemradio "Max"

View File

@@ -1,6 +1,7 @@
- listbox "Trigger suggestions":
- text: Commands
- option "compact Compact older conversation history" [selected]
- option "feedback record feedback about this session"
- option "goal set or view the goal for a long-running task"
- option "permission Switch the permission preset (sandbox mode + approval policy)"
- option "plan Enter or leave plan mode"

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace
@@ -30,8 +30,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode on, press to turn off": Plan
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: Details

View File

@@ -42,8 +42,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -21,8 +21,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -33,8 +33,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -37,8 +37,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -45,8 +45,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok

View File

@@ -0,0 +1,31 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
- img "API 密钥已配置"
- button "编辑 minimax-cn": 编辑
- button "删除 minimax-cn": 删除
- listitem:
- text: Acme Gateway 自定义
- button "编辑 Acme Gateway (acme-gateway)": 编辑
- button "删除 Acme Gateway (acme-gateway)": 删除
- button "添加提供方":
- img
- text: 添加提供方
- button "添加自定义提供方":
- img
- text: 添加自定义提供方

View File

@@ -25,12 +25,6 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com
- text: 推理强度
- combobox "推理强度":
- option "默认" [selected]
- option "off"
- option "high"
- option "max"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"

View File

@@ -1,13 +1,13 @@
kind=matches
summary=显示 9 / 共 42 处匹配 · 3 个文件
file=packages/client/ui-primitives/src/SearchBlock.tsx3
file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
line=138: export function SearchBlock(props: SearchBlockProps) {
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
line=35: const search = searchCardModel(block)
line=52: search={search}
line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
line=36: const search = searchCardModel(block)
line=56: search={search}
line=78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
expand=… 其余 4 行
recovery=Found 9 of 42 matches
@@ -15,13 +15,13 @@ packages/client/ui-primitives/src/SearchBlock.tsx
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
Line 138: export function SearchBlock(props: SearchBlockProps) {
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
packages/client/ui-conversation/src/client/contract/search-card-model.ts
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
packages/client/ui-conversation/src/client/toolviews/search-row.tsx
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
Line 35: const search = searchCardModel(block)
Line 52: search={search}
Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
packages/client/ui-tool/src/client/tool/models/search-card-model.ts
Line 45: export const CHAT_SEARCH_MAX_LINES = 8
Line 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
Line 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
Line 36: const search = searchCardModel(block)
Line 56: search={search}
Line 78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)

View File

@@ -31,9 +31,7 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
- img
@@ -44,8 +42,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model":
- text: Select model
- 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

@@ -31,9 +31,7 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
- img
@@ -42,8 +40,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- 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

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

View File

@@ -1,3 +1,4 @@
- listbox "Trigger suggestions":
- text: Skills
- option "policy-shared Available to both model and user invocation" [selected]
- option "policy-user-only user-only · Available only to user invocation"

View File

@@ -0,0 +1,45 @@
- banner:
- navigation "Session hierarchy":
- button "Load the snapshot-skill skill with" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Context injection skill-catalog":
- img
- img
- text: Context injection skill-catalog
- button "Think Load the requested skill.":
- img
- img
- text: Think Load the requested skill.
- button "Skill snapshot-skill" [expanded]:
- img
- text: Skill snapshot-skill
- region "Instructions": "Instructions <skill_content name=\"snapshot-skill\"> <skill_resources> Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. </skill_resources> <skill_instructions> Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. </skill_instructions> </skill_content>"
- button "Inspect"
- button "Think The skill is loaded.":
- img
- img
- text: Think The skill is loaded.
- paragraph: DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok

View File

@@ -0,0 +1,33 @@
- banner:
- navigation "Session hierarchy":
- button "/user-invoke-demo and confirm the fixtur" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Context injection user-invoke-demo":
- img
- img
- text: Context injection user-invoke-demo
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- 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 "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok

View File

@@ -0,0 +1,23 @@
- banner:
- navigation "Session hierarchy":
- button "Ask a research subagent to"
- text: /
- button "event-sourcing researcher" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Explain event sourcing in one sentence. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- 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 "Stop generating"
- button "Send message" [disabled]

View File

@@ -3,10 +3,10 @@
// A page load with a workspace already registered runs
// `WorkspacesService.startInitialSelection`: it connects the most recent
// workspace and opens its blank session. `openState` flips to `loading` the
// moment `open()` lands, which used to drive `data-phase=settling` on the
// conversation root — `visibility:hidden` over the composer seat and the
// header for the whole `session.history` round-trip, so the center column went
// blank and repainted, reading as a full-page refresh on every launch.
// moment `open()` lands; driving `data-phase=settling` on the conversation
// root from that flip would hide the composer seat and the header
// (`visibility:hidden`) for the whole `session.history` round-trip — the
// center column blanks and repaints like a full-page refresh on every launch.
//
// The unit spec pins the phase condition over hand-built stores. What only the
// assembled application can show is that the path a user actually takes
@@ -19,8 +19,9 @@
// 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: with the exemption reverted the held
// window is exactly when `settling` is painted and the composer is hidden.
// 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
@@ -145,7 +146,7 @@ describe('web e2e: startup auto-selection', () => {
// seat with `visibility:hidden`, which Playwright reports as not visible).
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
expect(await page.locator('textarea').first().isVisible()).toBe(true)
releaseHistory()

View File

@@ -365,22 +365,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
() => 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)

View File

@@ -0,0 +1,319 @@
// Web e2e scenario: the composer's independent Stop interrupts a running
// continuable child. The child holds its model turn open through a replay
// hang entry; the browser proves Send and Stop coexist, the parent-offline
// disabled-Send-with-Stop composer, the subagent.interrupt
// (never session.cancel) transport, the parked follow-up, and the FIFO resume
// on a waking send.
//
// Replay-binding note: only the PRIMARY script can hang, and scripts bind by
// first-call order, so the child issues the composition's first model call
// (claiming the overridden primary) and the parent's one UI prompt — needed
// so the non-blank parent renders its header catalog — binds to a derived
// child fixture afterwards.
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 { 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, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, 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 SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-interrupt', import.meta.url))
const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')
const MODE = webSnapshotMode()
const LABEL = 'event-sourcing researcher'
const INITIAL = 'Explain event sourcing in one sentence.'
const REARM = 'Keep working until I stop you again.'
const REARM_WAKE = 'Start that queued work now.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const WAKING = 'And add one concrete example.'
const REARMED_ANSWER = 're-armed setup answer'
const PARKED_ANSWER = 'parked follow-up answer'
const WAKING_ANSWER = 'waking answer'
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** Resolve on one exact child's next aborted turn end. */
function waitForAbortedTurn(scaffold: WebScaffold, childId: SessionId): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
off()
reject(new Error('interrupt did not reach an aborted 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()
if (event.data.reason.kind === 'aborted') resolve()
else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`))
})
})
}
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
function textCompletion(text: string): object {
return {
kind: 'chunks',
chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
{ type: 'finish', reason: { kind: 'stop' } },
],
}
}
describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let sidecarRoot: string
let rearmedReadyFile: string
let parent: Agent
let childId: SessionId
let tripwire: ReturnType<typeof watchConsole>
const apiCalls: string[] = []
beforeAll(async () => {
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-'))
const readyFile = join(sidecarRoot, 'hang-ready')
rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready')
// The child claims this whole-script replacement: the offline and online
// interrupt paths each hold one turn, then the parked and waking turns settle.
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
{ kind: 'hang', readyFile },
{ kind: 'hang', readyFile: rearmedReadyFile },
textCompletion(REARMED_ANSWER),
textCompletion(PARKED_ANSWER),
textCompletion(WAKING_ANSWER),
]))
await writeFile(
join(sidecarRoot, 'session.jsonl'),
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
)
// The parent's one prompted turn replays this recorded single text-only
// call (binding is positional, not lineage-aware).
const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl')
const base = await readFile(BASE_FIXTURE, 'utf8')
const [header, ...events] = base.trimEnd().split('\n')
if (header === undefined) throw new Error('base replay fixture has no header')
await writeFile(parentTurnPath, [
header
.replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"')
.replace(/"createdAt":\d+/, '"createdAt":1784998084442'),
...events,
'',
].join('\n'))
scaffold = await launchWebScaffold({
replayFixture: join(sidecarRoot, 'session.jsonl'),
replayOverride: join(sidecarRoot, 'replay.override.json'),
replayChildFixtures: [parentTurnPath],
})
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 root = scaffold.ctx.agents.roots()[0]
if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent')
parent = root
// The child's first model call claims the primary override and holds.
const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn',
label: LABEL,
signal: new AbortController().signal,
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
})
childId = started.childId
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
// One prompted parent turn makes the parent non-blank so the session
// header (and its subagent catalog action) renders.
const parentSettled = scaffold.whenTurnSettled()
const parentInput = page.locator('textarea:enabled').first()
await parentInput.fill('Ask a research subagent to explain event sourcing.')
await parentInput.press('Enter')
expect(await parentSettled).toBe(parent.id)
// Reload onto the restart baseline (the proven route to a freshly
// discovered catalog), with the child still live and running host-side.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
}, 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 interrupt UI teardown failed')
})
it('interrupts the live child through the parent-offline composer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
// Simulate a parent that went offline: the catalog delivers
// parentAvailable: false while the child Activation stays live (the
// interrupt RPC itself needs no live parent — covered host-side by
// subagent-interrupt.e2e.ts).
const pattern = '**/api/subagent.list'
await page.route(pattern, async (route) => {
const response = await route.fetch()
const body = await response.json() as {
result: { ok: true; value: { parentAvailable: boolean } } | { ok: false }
}
if (body.result.ok) body.result.value.parentAvailable = false
await route.fulfill({ response, json: body })
})
try {
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.getByRole('textbox', {
name: 'Parent session offline; sending is unavailable but you can still stop the run',
})
await input.waitFor({ timeout: 15_000 })
expect(await input.isDisabled()).toBe(true)
const stop = page.getByRole('button', { name: 'Stop generating' })
expect(await stop.count()).toBe(1)
expect(await stop.isEnabled()).toBe(true)
const send = page.getByRole('button', { name: 'Send message' })
expect(await send.count()).toBe(1)
expect(await send.isDisabled()).toBe(true)
await compareOrRefreshGolden(
OFFLINE_COMPOSER_EXPECTED,
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
MODE,
)
// Keep the continuable Activation resident after this first abort. The
// direct setup queue does not change the parent-offline UI contract: its
// input and Send remain disabled throughout the exercised browser path.
await scaffold.ctx.subagents.followup(
parent,
childId,
[{ type: 'text', text: REARM }],
{ source: { kind: 'user' }, signal: new AbortController().signal },
)
const aborted = waitForAbortedTurn(scaffold, childId)
const interruptResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subagent.interrupt')
await stop.click()
expect(((await (await interruptResponse).json()) as {
result: { ok: boolean; value?: { accepted: boolean } }
}).result).toMatchObject({ ok: true, value: { accepted: true } })
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
await aborted
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
// Wake the parked setup message only after cancellation converges. A
// second hang keeps the parent-available case independent from this stop.
await scaffold.ctx.subagents.followup(
parent,
childId,
[{ type: 'text', text: REARM_WAKE }],
{ source: { kind: 'user' }, signal: new AbortController().signal },
)
await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
} finally {
await page.unroute(pattern)
}
}, 60_000)
it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
// Reselect the child with the truthful catalog: parent available again.
await page.getByRole('navigation', { name: 'Session hierarchy' })
.getByRole('button').first().click()
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.getByRole('textbox', { name: 'Message the agent' })
await input.waitFor({ timeout: 15_000 })
expect(await input.isDisabled()).toBe(false)
// Queue a follow-up through Send while independent Stop remains available.
const promptResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subagent.prompt')
await input.fill(FOLLOWUP)
await page.getByRole('button', { name: 'Send message' }).click()
expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
.toMatchObject({ ok: true })
const aborted = waitForAbortedTurn(scaffold, childId)
const stop = page.getByRole('button', { name: 'Stop generating' })
expect(await stop.count()).toBe(1)
const interruptResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subagent.interrupt')
await stop.click()
expect(((await (await interruptResponse).json()) as {
result: { ok: boolean; value?: { accepted: boolean } }
}).result).toMatchObject({ ok: true, value: { accepted: true } })
// The addressed child stops through its own RPC, never the generic one.
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
await aborted
// Parked: the Activation stays resident and idle with the retained
// follow-up; the primary returns to Send without a new turn starting.
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
const child = scaffold.ctx.agents.get(childId)
expect(child).toBeDefined()
expect(child!.inbox.nextTurn).toHaveLength(2)
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
// Only the waking send resumes the parked queue, FIFO, to settlement.
await input.fill(WAKING)
await input.press('Enter')
await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(userTexts).toEqual([INITIAL, REARM, REARM_WAKE, FOLLOWUP, WAKING])
const turnEndKinds = loaded.events
.filter(event => event.type === 'turn/end')
.map(event => event.data.reason.kind)
expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md'])
})
})

View File

@@ -0,0 +1,176 @@
// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
// composition. A live continuable child holds its model turn open through a
// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
// proves from the real session state that the turn aborted, the follow-up
// parked without auto-starting a new turn, and a later waking send resumed the
// preserved FIFO order. No browser: the RPC surface is the product surface
// under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
import { existsSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent'
import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
const MODE = webSnapshotMode()
const INITIAL = 'Explain event sourcing in one sentence.'
const FOLLOWUP = 'Now give the same explanation to a human reader.'
const WAKING = 'And add one concrete example.'
type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
/** POST one unary RPC through the real HTTP carrier and unwrap its result. */
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
const response = await fetch(`${baseUrl}/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`,
method,
payload,
}),
})
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
return (await response.json() as { result: RpcResult<T> }).result
}
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
function textCompletion(text: string): object {
return {
kind: 'chunks',
chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
{ type: 'finish', reason: { kind: 'stop' } },
],
}
}
describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
let scaffold: WebScaffold
let sidecarRoot: string
let readyFile: string
let parentId: SessionId
let childId: SessionId
beforeAll(async () => {
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-'))
readyFile = join(sidecarRoot, 'hang-ready')
// Whole-script replacement: the child's three model calls are the hang
// (turn 1, interrupted), the parked follow-up's turn, and the waking turn.
// The parent never runs a turn, so the child claims this primary script.
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
{ kind: 'hang', readyFile },
textCompletion('resumed response one'),
textCompletion('resumed response two'),
]))
// Header-only primary fixture: the bare-array override replaces the
// derived script entirely; the path only anchors replay installation.
await writeFile(
join(sidecarRoot, 'session.jsonl'),
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
)
scaffold = await launchWebScaffold({
replayFixture: join(sidecarRoot, 'session.jsonl'),
replayOverride: join(sidecarRoot, 'replay.override.json'),
})
// A live parent Agent through the real API; no workspace or browser.
const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', {
cwd: scaffold.workspaceCwd,
})
if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)
parentId = sessionId(created.value.sessionId)
const parent = scaffold.ctx.agents.get(parentId)
if (parent === undefined) throw new Error('created parent session did not publish a live Agent')
const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn',
label: 'event-sourcing researcher',
signal: new AbortController().signal,
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
})
childId = started.childId
// The hang entry writes readyFile after its prefix chunks, immediately
// before waiting for cancellation: the deterministic "turn is open" gate.
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await scaffold?.close().catch((error: unknown) => failures.push(error))
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 interrupt teardown failed')
})
it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
// Queue the follow-up while the turn is still open, then interrupt.
const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
parentSessionId: parentId,
childSessionId: childId,
mode: 'continuable',
content: [{ type: 'text', text: FOLLOWUP }],
})
expect(queued).toMatchObject({ ok: true })
const settled = scaffold.whenTurnSettled()
const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', {
parentSessionId: parentId,
childSessionId: childId,
mode: 'continuable',
})
expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
// accepted acknowledges the admitted cancel, not quiescence: wait for the
// aborted turn/end (the composition's first turn/end) before asserting.
expect(await settled).toBe(childId)
// Parked, not resumed: the Activation stays resident with an idle driver,
// the follow-up is retained, and no second turn opened.
const child = scaffold.ctx.agents.get(childId)
expect(child).toBeDefined()
expect(child!.status).toBe('idle')
expect(child!.inbox.nextTurn).toHaveLength(1)
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1)
expect((lastEnd)?.data.reason.kind).toBe('aborted')
// Only an explicit waking send resumes the parked queue, FIFO, then the
// child runs both turns to completion and settles.
const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
parentSessionId: parentId,
childSessionId: childId,
mode: 'continuable',
content: [{ type: 'text', text: WAKING }],
})
expect(waking).toMatchObject({ ok: true })
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
// Human-origin messages only: the real composition also injects
// runtime-context snapshots as non-user-source messages.
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING])
const turnEndKinds = loaded.events
.filter(event => event.type === 'turn/end')
.map(event => (event).data.reason.kind)
expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed'])
}, 120_000)
})

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 71, two items `in_progress`)
// two surfaces the fixture's parallel plan (turn 72, 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

View File

@@ -1,6 +1,6 @@
// 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 used to hand the
// 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
// 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

View File

@@ -91,8 +91,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; re-registration after
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
// path is gone), so callers opt in only where a fresh attach is possible.
// a delete mints a fresh blank Session+Agent too (no cwd-based reuse
// exists), so callers opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
@@ -254,7 +254,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
// a supported reversible flow. It creates a fresh Workspace id and does
// NOT re-adopt the retained (non-blank) Session; the New Session flow
// mints a fresh blank session and attaches it to the new registration
// (the old cwd-only blank reuse is gone, so the account is never empty).
// (no cwd-based blank reuse exists, so the account is never empty).
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
@@ -482,7 +482,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// The card is REACHABLE: it sits 8px off the row, so getting to it means
// crossing ground that belongs to neither. Hovering it must not dismiss
// it — the regression this scenario guards.
// it — the hazard this scenario pins.
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
await card.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
@@ -515,10 +515,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const item = page.getByRole('menuitem', { name: 'Rename' })
await item.waitFor({ timeout: 5_000 })
// Into the list, then back up to the trigger across the 4px gap below it:
// that return trip used to fire the list's pointerleave and close the
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
// the pointer ON the trigger, so entering the list has to come first for
// the return to be a real departure.
// without the gap-crossing grace, that return trip fires the list's
// pointerleave and closes the menu — a hesitating pointer loses it.
// Order matters — clicking leaves the pointer ON the trigger, so entering
// the list has to come first for the return to be a real departure.
await item.hover()
await page.waitForTimeout(POINTER_TRANSIT_MS)
await trigger.hover()