Merge remote-tracking branch 'upstream/master' into feat/web-workspace-file-links

# Conflicts:
#	apps/web/tsconfig.json
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/client/ui-conversation/src/client/chat/chat-flow.ts
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/host/apiproxy/src/native-path-opener.ts
#	tsconfig.host.json
This commit is contained in:
ZiyaZhang
2026-08-06 04:44:13 -07:00
3480 changed files with 101380 additions and 55152 deletions

View File

@@ -2,7 +2,6 @@
// the same locale-aware, in-page risk confirmation. Zero model calls: the
// scenario boots the shipped Web composition and exercises the real
// permission projection, client command path, HTTP RPC, and pushed update.
import { mkdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -12,27 +11,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
/**
* connectFreshWorkspace twin over the product default Chinese locale (the
* shared helper's anchors assume the English page every other scenario
* boots; this scenario deliberately keeps zh, so the localized picker
* copy is the anchor set).
*/
async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: '打开', exact: true }).click()
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
.waitFor({ timeout: 15_000 })
}
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
@@ -70,14 +49,7 @@ describe('web e2e: Full access confirmation', () => {
const access = page.locator('button[aria-label^="访问模式"]').first()
await access.waitFor({ timeout: 10_000 })
// Normalize the starting preset through the real command path. The
// shipped web config may already start at Full access.
if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
await access.click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('访问模式当前Workspace Write')
}
expect(await access.getAttribute('aria-label')).toBe('访问模式当前Workspace Write')
await access.click()
await page.getByRole('menuitem', { name: 'Full access' }).click()

View File

@@ -77,12 +77,14 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
// The composer's own text cap, measured on the live textarea before the
// takeover replaces it. The panel's scroll region must stop at the same
// height (the designer's requirement: one cap for the composer seat), and
// measuring it here keeps the assertion free of the px value itself.
// The composer's own text cap, measured on the live draft scrollport before
// the takeover replaces it — the box that carries the cap, while the
// textarea inside it is as tall as the whole draft. The panel's scroll
// region must stop at the same height (the designer's requirement: one cap
// for the composer seat), and measuring it here keeps the assertion free of
// the px value itself.
await input.fill(CAP_PROBE)
const composerCap = await input.evaluate(el => el.clientHeight)
const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
expect(composerCap).toBeGreaterThan(0)
await input.fill('')

View File

@@ -0,0 +1,82 @@
// Web e2e scenario: a cancelled Bash call can settle without terminal-card
// material. Borrow the real cancellation fixture and prove the keyed Bash row
// still exposes the recorded command and full error without any model call.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'bash-abort-row-web-e2e'
const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.'
describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => {
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-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('expands the aborted row to its command and full error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row'))
const row = page.locator('[data-sample="bash"]').first()
const call = row.locator('xpath=..')
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
await call.getByText('IN', { exact: true }).waitFor()
await call.getByText('OUT', { exact: true }).waitFor()
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
// The borrowed fixture's UTC date is still the previous day in PDT;
// the disclosure golden must not depend on the runner timezone.
.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

@@ -1,15 +1,15 @@
// @vitest-environment jsdom
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
// ModuleLoader path (loadBundle) and proves the boot graph
// assembles — staged activation across the immediately tier and the inject
// layers, per-plugin CSS injection, and a rendered journey reaching chat
// content from the keyless FixtureApiClient transport.
//
// Behavior assertions do NOT belong here: component and wiring behavior is
// pinned by the per-package suites (SlotTestRuntime benches over src), which
// this smoke's plugin set cannot influence — bundling, module-table
// resolution, and boot layering are the only failure modes left to it.
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -91,11 +91,11 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
@@ -105,12 +105,37 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The resident fixture has both a question and an approval; composer routing
// exposes the question first, and the assembled workspace plugin mirrors that
// actionable wait instead of the underlying running state.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
within(waitingRow).getByText('Waiting for answer')
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
fireEvent.click(waitingTitle)
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
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.
fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
// The fixture mirrors all three token-meter projections, so the assembled
// ContextMeter reaches its composition panel instead of only the occupancy
// fallback path.
const contextTrigger = await screen.findByRole('button', { name: /of context used/ })
fireEvent.click(contextTrigger)
const contextPanel = await screen.findByRole('dialog', { name: 'of context used' })
within(contextPanel).getByText('System prompt')
within(contextPanel).getByText('Tools')
within(contextPanel).getByText('Messages')
// The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/

View File

@@ -0,0 +1,328 @@
// Web e2e contract for a conversation grown through the real composer rather
// than pre-seeded history. Twelve deterministic replay turns exercise repeated
// send/settle/render cycles, including two real bash executions and one long,
// multi-chunk final turn. Assertions stay semantic: no host timing, heap, or
// mounted-row cardinality is treated as a correctness contract.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 { 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 {
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const TURN_COUNT = 12
const TOOL_TURNS = [4, 9] as const
const STREAM_PACE_MS = 10
interface TurnSpec {
readonly index: number
readonly prompt: string
readonly userMarker: string
readonly firstMarker: string
readonly doneMarker: string
readonly deltas: readonly string[]
readonly callId?: ReturnType<typeof CallId>
readonly toolResultMarker?: string
}
function suffix(index: number): string {
return String(index).padStart(3, '0')
}
function longFinalPrompt(userMarker: string): string {
return [
`${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`,
...Array.from(
{ length: 36 },
(_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`,
),
'Return one continuous response and finish with the requested completion marker.',
].join('\n')
}
function turnSpec(index: number): TurnSpec {
const id = suffix(index)
const userMarker = `CONTINUOUS_CHAT_USER_${id}`
const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}`
const doneMarker = `CONTINUOUS_CHAT_DONE_${id}`
const deltaCount = index === TURN_COUNT ? 36 : 8
const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => {
if (chunkIndex === 0) return `${firstMarker} `
if (chunkIndex === deltaCount - 1) return `${doneMarker}.`
return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. `
})
if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) {
return {
index,
prompt: index === TURN_COUNT
? longFinalPrompt(userMarker)
: `${userMarker} Continue this same conversation through turn ${String(index)}.`,
userMarker,
firstMarker,
doneMarker,
deltas,
}
}
return {
index,
prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`,
userMarker,
firstMarker,
doneMarker,
deltas,
callId: CallId(`continuous-chat-tool-${id}`),
toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`,
}
}
function textStream(spec: TurnSpec): StreamChunk[] {
const response = spec.deltas.join('')
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{
type: 'usage',
usage: {
inputTokens: Math.ceil(spec.prompt.length / 4),
outputTokens: Math.ceil(response.length / 4),
},
},
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function toolStream(spec: TurnSpec): StreamChunk[] {
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
throw new Error(`turn ${String(spec.index)} has no tool identity`)
}
const args = JSON.stringify({
command: `printf '${spec.toolResultMarker}\\n'`,
description: spec.toolResultMarker,
})
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{
type: 'tool-call-delta',
index: 0,
id: spec.callId,
name: 'bash',
argumentsDelta: args,
},
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args },
},
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc {
return specs.flatMap((spec): ReplayEntry[] => {
const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) }
return spec.callId === undefined
? [final]
: [{ kind: 'chunks', chunks: toolStream(spec) }, final]
})
}
function userText(event: Extract<SessionEvent, { type: 'user/message' }>): string {
return event.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
return event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
return event.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
describe('web e2e: continuous conversation grown through the composer', () => {
let browser: Browser
let page: Page
let replayDir: string
let scaffold: WebScaffold
let tripwire: ReturnType<typeof watchConsole>
const consoleWarnings: string[] = []
const sessionEvents: SessionEvent[] = []
const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
replayContextWindow: 10_000_000,
paceMs: STREAM_PACE_MS,
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
sessionEvents.push(event)
})
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
page.on('console', (message) => {
if (message.type() === 'warning') consoleWarnings.push(message.text())
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
}, 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, 'continuous Chat e2e cleanup failed')
})
it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
const composer = page.locator('textarea:enabled').last()
await composer.waitFor({ timeout: 15_000 })
let sessionId: SessionId | undefined
for (const spec of specs) {
const eventStart = sessionEvents.length
expect(await composer.inputValue()).toBe('')
expect(await composer.isEnabled()).toBe(true)
await composer.fill(spec.prompt)
expect(await composer.inputValue()).toBe(spec.prompt)
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 })
const echoedUser = sessionEvents.slice(eventStart).find(
(event): event is SessionEvent<'user/message'> => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& userText(event).includes(spec.userMarker)
),
)
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)}"]`)
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)
await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const settledSessionId = await settled
if (sessionId === undefined) {
sessionId = settledSessionId
} else {
expect(settledSessionId).toBe(sessionId)
}
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('')
await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
const turnEvents = sessionEvents.slice(eventStart)
const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
event.type === 'turn/start'
))
const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
event.type === 'user/message' && event.data.source.kind === 'user'
))
const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
event.type === 'assistant/message'
))
const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))
const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
expect(turnStarts).toHaveLength(1)
expect(turnStarts[0]?.data.turn).toBe(spec.index)
expect(users).toHaveLength(1)
expect(users[0]?.seq).toBe(echoedUser.seq)
expect(userText(users[0]!)).toBe(spec.prompt)
expect(finalAssistants).toHaveLength(1)
expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
expect(turnEnds).toHaveLength(1)
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)}"]`)
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
expect(calls).toHaveLength(0)
expect(results).toHaveLength(0)
continue
}
expect(calls).toHaveLength(1)
expect(results).toHaveLength(1)
expect(calls[0]?.data).toMatchObject({
turn: spec.index,
callId: spec.callId,
name: 'bash',
})
expect(results[0]?.data.turn).toBe(spec.index)
expect(results[0]?.data.message.source.callId).toBe(spec.callId)
expect(results[0]?.data.message.content[0].isError).toBe(false)
expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
const disclosure = toolRow.locator('[data-sample="bash"]')
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
// The collapsed summary deliberately repeats the result marker; the
// last exact match is the expanded terminal output owned by this call.
await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
}
if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
event.type === 'turn/end' && event.data.reason.kind === 'completed'
))).toHaveLength(TURN_COUNT)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
expect(sessionEvents.filter(event => (
event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
)).length).toBeGreaterThan(30)
expect(consoleWarnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 180_000)
})

View File

@@ -0,0 +1,273 @@
// 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.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 { 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 { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SESSION_ID = 'chat-long-interactions-e2e'
const FIXTURE_TURNS = 88
const TOOL_TURN = FIXTURE_TURNS
const BRANCH_TURN = 80
const TARGET_CALL_1 = 'chat-scroll-088-1'
const TARGET_CALL_2 = 'chat-scroll-088-2'
const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
const FIXTURE = createChatScrollFixture({
markerPrefix: 'INTERACTION',
title: 'CHAT_INTERACTION long semantic identity session',
turns: FIXTURE_TURNS,
})
function continuationChunks(): StreamChunk[] {
const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
{ type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
return { kind: 'chunks', chunks }
}
function carries(event: SessionEvent, marker: string): boolean {
return JSON.stringify(event).includes(marker)
}
function textContent(content: readonly unknown[]): string {
return content.flatMap((block) => {
if (typeof block !== 'object' || block === null) return []
const candidate = block as { type?: unknown; text?: unknown }
return candidate.type === 'text' && typeof candidate.text === 'string'
? [candidate.text]
: []
}).join('')
}
async function nextPaint(page: Page): Promise<void> {
await page.evaluate(async () => {
await document.fonts.ready
await new Promise<void>(resolve => requestAnimationFrame(() => {
requestAnimationFrame(() => { resolve() })
}))
})
}
async function openSeed(page: Page): Promise<void> {
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })
const resultCount = await results.count()
if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
await results.click()
await results.click()
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
.last().waitFor({ timeout: 30_000 })
await nextPaint(page)
}
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
const scrollport = page.locator('[data-conversation-scroll]')
const box = await scrollport.boundingBox()
if (box === null) throw new Error('conversation scrollport has no layout box')
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
for (let attempt = 0; attempt < 20; attempt += 1) {
if (await page.locator(selector).count() > 0) return
await page.mouse.wheel(0, deltaY)
await nextPaint(page)
}
throw new Error(`semantic Chat target did not mount: ${selector}`)
}
function requiredEvent<T extends SessionEvent['type']>(
events: readonly SessionEvent[],
type: T,
marker: string,
): Extract<SessionEvent, { type: T }> {
const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
candidate.type === type && carries(candidate, marker)
))
if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
return event
}
describe('web e2e: long Chat interaction contract', () => {
let browser: Browser
let page: Page
let replayDir: string
let scaffold: WebScaffold
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
await writeFile(replayOverride, JSON.stringify(replay))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
replayContextWindow: 10_000_000,
paceMs: 18,
})
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeed(page)
}, 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, 'long Chat interaction cleanup failed')
})
it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
if (source === undefined) throw new Error('seeded long-history agent is not attached')
const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end' && event.data.turn === BRANCH_TURN
))
if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
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 call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
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 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)}`,
]
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
))))
expect(groupKeys[0]).not.toBeNull()
expect(groupKeys[1]).toBe(groupKeys[0])
const summary1 = call1.locator('[data-sample="bash"]')
const summary2 = call2.locator('[data-sample="bash"]')
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
expect(await summary2.getAttribute('aria-expanded')).toBe('false')
await summary2.focus()
await summary2.press('Enter')
await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
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)}"]`)
expect(await userRow.textContent()).toContain(branchUserMarker)
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await userRow.hover()
await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
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 expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
{ timeout: 15_000 },
).toBeDefined()
const child = scaffold.ctx.agents.list()
.find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
if (child === undefined) throw new Error('message branch did not create a child session')
expect(child.session.header.seedLength).toBe(boundary.seq + 1)
expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
.getByRole('button').last()
await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
.toBe(`${FIXTURE.title} (1)`)
await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const settled = scaffold.whenTurnSettled(60_000)
const composer = page.locator('textarea:enabled').last()
await composer.fill(CONTINUE_PROMPT)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await settled).toBe(child.session.id)
await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
expect(await composer.inputValue()).toBe('')
expect(await composer.isEnabled()).toBe(true)
expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
expect(child.session.events.filter(event => (
event.type === 'user/message' && carries(event, CONTINUE_PROMPT)
))).toHaveLength(1)
const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))
expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 180_000)
})

View File

@@ -0,0 +1,686 @@
// Browser geometry contracts for a long Chat transcript. These scenarios are
// deliberately virtualizer-neutral: they assert semantic-row position,
// bottom ownership, interaction state, and the real outer scroll host rather
// than DOM cardinality or implementation-specific spacer markup.
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
const REPLAY_CONTEXT_WINDOW = 10_000_000
const STREAM_PACE_MS = 24
const GEOMETRY_TOLERANCE = 2
const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
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 HISTORY_FIXTURE = createChatScrollFixture({
markerPrefix: 'HISTORY',
title: 'CHAT_SCROLL_HISTORY long paging session',
})
const TOOL_FIXTURE = createChatScrollFixture({
markerPrefix: 'TOOL',
title: 'CHAT_SCROLL_TOOL live tool session',
})
const RESTORE_FIXTURE_A = createChatScrollFixture({
markerPrefix: 'RESTORE_A',
title: 'CHAT_SCROLL_RESTORE_A long session',
})
const RESTORE_FIXTURE_B = createChatScrollFixture({
markerPrefix: 'RESTORE_B',
title: 'CHAT_SCROLL_RESTORE_B comparison session',
turns: 32,
})
interface ScrollGeometry {
readonly distanceFromBottom: number
readonly scrollTop: number
}
interface FlowAnchor {
readonly key: string
readonly top: number
}
interface ScrollWorld {
readonly events: SessionEvent[]
readonly page: Page
readonly replayDir?: string
readonly scaffold: WebScaffold
readonly tripwire: ReturnType<typeof watchConsole>
}
interface ScrollWorldOptions {
readonly failureShot: string
readonly replay?: ReplayOverrideDoc
readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
}
function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
const deltas = Array.from({ length: deltaCount }, (_, index) => {
if (index === 0) return `${first} `
if (index === deltaCount - 1) return `${done}.`
return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
})
const response = deltas.join('')
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{
type: 'usage',
usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
},
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function toolStream(): StreamChunk[] {
const command = [
`: > ${TOOL_READY_FILE}`,
`while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
'line=1',
`while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
].join('; ')
const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{
type: 'tool-call-delta',
index: 0,
id: LIVE_TOOL_CALL_ID,
name: 'bash',
argumentsDelta: args,
},
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
},
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
return { kind: 'chunks', chunks }
}
async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
let replayDir: string | undefined
let scaffold: WebScaffold | undefined
let page: Page | undefined
try {
if (options.replay !== undefined) {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(options.replay))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
paceMs: STREAM_PACE_MS,
replayContextWindow: REPLAY_CONTEXT_WINDOW,
})
} else {
scaffold = await launchWebScaffold({})
}
for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
const events: SessionEvent[] = []
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
page = await newEnglishPage(browser, 900)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Session-list bootstrap can replace the controlled search state. Wait
// for the seeded baseline before openSeed starts the lazy content query.
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
return {
events,
page,
scaffold,
tripwire,
...(replayDir === undefined ? {} : { replayDir }),
}
} catch (error) {
const failures: unknown[] = [error]
if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
}
if (failures.length === 1) throw error
throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
}
}
async function closeScrollWorld(world: ScrollWorld): Promise<void> {
const failures: unknown[] = []
// newEnglishPage/browser.newPage owns an isolated context. Close the whole
// context so its SSE connection and cache cannot leak into the next world
// in this file's shared Chromium process.
await world.page.context().close().catch((error: unknown) => failures.push(error))
await world.scaffold.close().catch((error: unknown) => failures.push(error))
if (world.replayDir !== undefined) {
await rm(world.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, 'chat-scroll browser world cleanup failed')
}
async function withScrollWorld(
options: ScrollWorldOptions,
run: (world: ScrollWorld) => Promise<void>,
): Promise<void> {
const world = await launchScrollWorld(options)
let runFailure: unknown
try {
await run(world)
} catch (error) {
runFailure = error
try {
await saveFailureShot(world.page, options.failureShot)
} catch {
// Best-effort evidence must never prevent cleanup of the owned world.
}
}
let cleanupFailure: unknown
try {
await closeScrollWorld(world)
} catch (error) {
cleanupFailure = error
}
if (runFailure !== undefined && cleanupFailure !== undefined) {
throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
}
if (runFailure !== undefined) throw runFailure
if (cleanupFailure !== undefined) throw cleanupFailure
}
async function nextPaint(page: Page): Promise<void> {
await page.evaluate(async () => {
await document.fonts.ready
await new Promise<void>(resolve => requestAnimationFrame(() => {
requestAnimationFrame(() => { resolve() })
}))
})
}
function scrollGeometry(page: Page): Promise<ScrollGeometry> {
return page.locator('[data-conversation-scroll]').evaluate(host => ({
distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
scrollTop: host.scrollTop,
}))
}
async function conversationTurns(page: Page): Promise<number> {
const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
await stats.waitFor({ timeout: 15_000 })
const value = await stats.textContent()
const match = value?.match(/^(\d+) turns · \d+ steps$/)
if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
return Number(match[1])
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
// persisted first-message marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient
// empty-state paint is used as a barrier.
await search.fill(fixture.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
await results.click()
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
if (tailMarker !== undefined) {
await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
}
await nextPaint(page)
}
async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
const box = await page.locator('[data-conversation-scroll]').boundingBox()
if (box === null) throw new Error('conversation scrollport has no layout box')
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
await page.mouse.wheel(0, 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
await wheelTranscript(page, -2_400)
}
await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
}
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
for (let attempt = 0; attempt < 16; attempt += 1) {
if (await page.locator(selector).count() > 0) return
await wheelTranscript(page, deltaY)
}
throw new Error(`selector did not mount during transcript wheel: ${selector}`)
}
async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
const target = page.locator(selector)
for (let attempt = 0; attempt < 32; attempt += 1) {
if (await target.count() > 0 && await target.evaluate((row) => {
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
if (host === null) return false
const viewport = host.getBoundingClientRect()
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
const rect = row.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})) return
await wheelTranscript(page, deltaY)
}
throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
}
function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
return page.locator('[data-conversation-scroll]').evaluate((host) => {
const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
const viewport = host.getBoundingClientRect()
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
const visible = rows.filter((candidate) => {
const rect = candidate.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})
const row = visible[0]
if (row?.dataset.chatAnchorKey === undefined) {
throw new Error(`no visible settled Chat row: ${JSON.stringify({
composerTop: visibleBottom,
host: { bottom: viewport.bottom, top: viewport.top },
rows: rows.slice(0, 4).map(candidate => ({
callId: candidate.dataset.chatCallId,
key: candidate.dataset.chatAnchorKey,
rect: {
bottom: candidate.getBoundingClientRect().bottom,
top: candidate.getBoundingClientRect().top,
},
})),
totalRows: rows.length,
})}`)
}
return {
key: row.dataset.chatAnchorKey,
top: row.getBoundingClientRect().top - viewport.top,
}
})
}
function flowTop(page: Page, key: string): Promise<number> {
return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
const host = row.closest('[data-conversation-scroll]')
if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
return row.getBoundingClientRect().top - host.getBoundingClientRect().top
}, key)
}
async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
timeout: 10_000,
message: `flow row ${anchor.key} moved relative to the transcript viewport`,
}).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
}
async function expectBottom(page: Page): Promise<void> {
await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
timeout: 10_000,
}).toBeLessThanOrEqual(1)
}
async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
const row = node.closest('[data-chat-flow-key], [data-streaming]')
const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
throw new Error('latest marker or composer geometry is unavailable')
}
return {
composerTop: composer.getBoundingClientRect().top,
rowBottom: row.getBoundingClientRect().bottom,
}
})
expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
}
async function loadEarlierWithAnchor(page: Page): Promise<void> {
await wheelToHistoryStart(page)
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
await older.waitFor({ timeout: 10_000 })
const anchor = await visibleFlowAnchor(page)
const before = await conversationTurns(page)
await older.click()
await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
await nextPaint(page)
await expectSameFlowTop(page, anchor)
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
function eventCarries(event: SessionEvent, marker: string): boolean {
return JSON.stringify(event).includes(marker)
}
function assertClean(world: ScrollWorld): void {
expect(world.tripwire.pageErrors).toEqual([])
expect(world.tripwire.warnings).toEqual([])
}
let browser: Browser
describe('web e2e: long Chat scroll contract', () => {
beforeAll(async () => {
browser = await chromium.launch()
})
afterAll(async () => {
await browser?.close()
})
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-history-stream',
replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
}, async (world) => {
await openSeed(
world.page,
HISTORY_FIXTURE,
HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
)
await expectBottom(world.page)
let releaseHistory = (): void => {}
let held = false
let releaseGate: (() => void) | undefined
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
releaseHistory = () => { releaseGate?.() }
await world.page.route('**/api/session.history', async (route) => {
const request = route.request().postDataJSON() as {
method?: string
payload?: { beforeSeq?: number }
}
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
held = true
await gate
}
await route.continue()
})
const settled = world.scaffold.whenTurnSettled(60_000)
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_TEXT_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
await wheelToHistoryStart(world.page)
const beforeTurns = await conversationTurns(world.page)
await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
await wheelTranscript(world.page, 420)
const readerAnchor = await visibleFlowAnchor(world.page)
const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 10_000 },
).toBeGreaterThan(chunksAfterAnchor + 5)
releaseHistory()
await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
await nextPaint(world.page)
await expectSameFlowTop(world.page, readerAnchor)
} finally {
releaseHistory()
}
await settled
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await world.page.unroute('**/api/session.history')
let additionalPages = 0
while (additionalPages < 8) {
await wheelToHistoryStart(world.page)
if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
await loadEarlierWithAnchor(world.page)
additionalPages += 1
}
expect(additionalPages).toBeGreaterThan(0)
expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-live-tool',
replay: [
replayEntry(toolStream()),
replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
],
seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_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, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
const settled = world.scaffold.whenTurnSettled(60_000)
let released = false
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_TOOL_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
await liveRow.waitFor({ timeout: 15_000 })
expect(await liveRow.getAttribute('data-state')).toBe('running')
await expectBottom(world.page)
await wheelTranscript(world.page, -1_200)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).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.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
{ 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)
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
await expectBottom(world.page)
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_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expectBottom(world.page)
await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
const liveRow = world.page.locator(liveRowSelector)
await wheelUntilVisible(world.page, liveRowSelector, -300)
const toolAnchor = await liveRow.evaluate((row) => {
const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
if (flow?.dataset.chatAnchorKey === undefined || host === null) {
throw new Error('live tool row has no settled flow identity')
}
return {
key: flow.dataset.chatAnchorKey,
top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
}
})
await liveRow.click()
await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
await expectSameFlowTop(world.page, toolAnchor)
await wheelToHistoryStart(world.page)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
await expectBottom(world.page)
await wheelUntilMounted(world.page, liveRowSelector, -1_100)
const restoredRow = world.page.locator(liveRowSelector)
await restoredRow.waitFor({ timeout: 10_000 })
expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-restore-composer',
seeds: [
{ fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
{ fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
],
}, async (world) => {
await openSeed(
world.page,
RESTORE_FIXTURE_A,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await loadEarlierWithAnchor(world.page)
await loadEarlierWithAnchor(world.page)
await wheelToHistoryStart(world.page)
await wheelTranscript(world.page, 1_300)
const sessionAnchor = await visibleFlowAnchor(world.page)
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.setViewportSize({ width: 700, height: 900 })
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
// this scenario switches sessions while pinning the narrow Chat scroll owner.
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor)
await openSeed(
world.page,
RESTORE_FIXTURE_B,
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
)
await openSeed(
world.page,
RESTORE_FIXTURE_A,
)
await expectSameFlowTop(world.page, sessionAnchor)
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
await backToBottom.evaluate((button) => {
if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
button.click()
const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
.find(tab => tab.textContent?.trim() === 'Trajectory')
if (!(trajectory instanceof HTMLElement)) {
throw new Error('Trajectory tab is unavailable during pinned remount')
}
trajectory.click()
})
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await expectBottom(world.page)
await openSeed(
world.page,
RESTORE_FIXTURE_B,
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
)
await openSeed(
world.page,
RESTORE_FIXTURE_A,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await expectBottom(world.page)
const composer = world.page.locator('textarea:enabled').last()
const longDraft = Array.from(
{ length: 18 },
(_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
).join('\n')
await composer.fill(longDraft)
await nextPaint(world.page)
await expectBottom(world.page)
await expectMarkerAboveComposer(
world.page,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await composer.fill('short draft')
await nextPaint(world.page)
await wheelTranscript(world.page, -900)
const resizeAnchor = await visibleFlowAnchor(world.page)
await composer.fill(longDraft)
await nextPaint(world.page)
await expectSameFlowTop(world.page, resizeAnchor)
await composer.fill('short draft')
await nextPaint(world.page)
await expectSameFlowTop(world.page, resizeAnchor)
const beforeChain = await scrollGeometry(world.page)
await composer.hover()
await world.page.mouse.wheel(0, -320)
await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
.toBeLessThan(beforeChain.scrollTop)
assertClean(world)
})
}, 180_000)
})

View File

@@ -0,0 +1,234 @@
// Synthetic long-chat history for browser behavior contracts. The fixture is
// generated through Session so pagination exercises the same event shapes as
// persisted conversations, while unique markers identify semantic rows
// without depending on CSS-module names or virtualizer DOM positions.
import {
CallId,
createAssistantMessage,
createToolResultMessage,
createUserMessage,
} from '@deepseek-ai/dsh-llm'
import {
SESSION_FORMAT_VERSION,
Session,
SessionId,
} from '@deepseek-ai/dsh-session'
// Carries the session/title event declaration into this fixture builder.
import type {} from '@deepseek-ai/dsh-session-title'
/** Options for one deterministic long-chat fixture. */
export interface ChatScrollFixtureOptions {
/** Marker namespace, used when two sessions share one browser world. */
readonly markerPrefix: string
/** Searchable title projected into the sidebar. */
readonly title: string
/** Number of closed turns to generate. */
readonly turns?: number
}
/** Semantic marker helpers returned with a generated fixture. */
interface ChatScrollMarkers {
/** Marker painted in the human message for a turn. */
user(turn: number): string
/** Marker painted in the final assistant message for a turn. */
assistant(turn: number): string
/** Marker painted in one seeded bash call and result. */
tool(turn: number, index: number): string
}
/** Generated JSONL plus the stable facts browser scenarios assert. */
export interface ChatScrollFixture {
readonly log: string
readonly markers: ChatScrollMarkers
readonly title: string
readonly turns: number
}
const DEFAULT_TURNS = 88
const TOOL_INTERVAL = 8
const CODE_INTERVAL = 11
function text(value: string): { type: 'text'; text: string }[] {
return [{ type: 'text', text: value }]
}
function suffix(turn: number): string {
return String(turn).padStart(3, '0')
}
function markerHelpers(prefix: string): ChatScrollMarkers {
return {
user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
}
}
function appendRequestHeader(session: Session, turn: number, step: number): void {
session.append('request/header', {
header: {
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
},
reason: turn === 1 && step === 1 ? 'initial' : 'change',
})
}
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
session.append('assistant/message', {
turn,
step,
message: createAssistantMessage({
content: text(body),
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
usage: {
inputTokens: 2_000 + turn * 7,
outputTokens: 180 + step * 20,
},
}, { surfaceOp: 'append' })
}
function codeBlock(turn: number): string {
if (turn % CODE_INTERVAL !== 0) return ''
const lines = Array.from(
{ length: 30 },
(_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
)
return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
}
function appendToolStep(
session: Session,
markers: ChatScrollMarkers,
turn: number,
): void {
const calls = [1, 2].map((index) => {
const marker = markers.tool(turn, index)
const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
const args = JSON.stringify({
command: `printf '${marker}\\n'`,
description: marker,
})
return { args, callId, marker }
})
session.append('assistant/message', {
turn,
step: 1,
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
...calls.map(call => ({
type: 'tool-call' as const,
id: call.callId,
name: 'bash',
arguments: call.args,
})),
],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
}, { surfaceOp: 'append' })
for (const call of calls) {
const source = session.append('tool/call', {
turn,
step: 1,
callId: call.callId,
name: 'bash',
arguments: call.args,
})
session.append('tool/result', {
turn,
step: 1,
message: createToolResultMessage({
callId: call.callId,
content: text(Array.from(
{ length: 12 },
(_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
).join('\n')),
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
}
function fixtureLog(session: Session): string {
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: Date.now() - 60_000,
cwd: '{{cwd}}',
delegationDepth: 0,
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
/**
* Build a multi-page conversation with prose, fenced code, and paired bash
* calls/results. Every turn is closed, so cold resume cannot repair or mutate
* the seed before the browser observes it.
* @param options - Fixture identity and optional turn count.
* @returns Canonical JSONL and semantic marker helpers.
*/
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
const turns = options.turns ?? DEFAULT_TURNS
const markers = markerHelpers(options.markerPrefix)
const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', {
turn,
})
const user = session.append('user/message', createUserMessage({
content: text(
`${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
+ 'Keep the visible message stable while history, tools, and new output change around it.',
),
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (turn === 1) {
session.append('session/title', {
title: options.title,
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
}
session.append('step/start', { turn, step: 1 })
appendRequestHeader(session, turn, 1)
if (turn % TOOL_INTERVAL === 0) {
appendToolStep(session, markers, turn)
session.append('step/end', { turn, step: 1 })
session.append('step/start', { turn, step: 2 })
appendRequestHeader(session, turn, 2)
appendAssistant(
session,
turn,
2,
`${markers.assistant(turn)} Both tool results are accounted for. `
+ `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
)
session.append('step/end', { turn, step: 2 })
} else {
appendAssistant(
session,
turn,
1,
`${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
+ `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
+ 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
+ `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
)
session.append('step/end', { turn, step: 1 })
}
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return { log: fixtureLog(session), markers, title: options.title, turns }
}

View File

@@ -112,7 +112,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
// the bash sub-call landed in the bash sample registration.
const nest = page.locator('[data-subcalls]').first()
await nest.waitFor({ timeout: 10_000 })
expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1)
expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1)
// The failing read sub-call wears the same error state a native failed
// row wears (the recorded program tolerates a read of missing.txt).
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
@@ -123,7 +123,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
const nest = page.locator('[data-subcalls]').first()
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
await nest.locator('[data-sample="bash-global"]').first().click()
await nest.locator('[data-sample="bash"]').first().click()
// Tool rows do not drive layout geometry; the Session's default panel stays closed.
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,31 @@
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
// GLYPHS, not just its caret.
// GLYPHS AND ITS CARET AS ONE.
//
// The composer paints its text in two stacked layers (see
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
// `<textarea>` carries the value, the selection and the caret but renders its
// own glyphs `color: transparent`, and every visible character is painted by the
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint. The backdrop is `position: absolute;
// inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
// browser links its scroll offset to the textarea's.
// highlight, the chips and the ghost hint.
//
// So past the cap the textarea scrolled and the words did not: the caret walked
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
// backdrop on every textarea `scroll`, which is the one event every way of
// moving the box ends in.
// Two layers can only stay together by moving together. They now 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
// its offset to the backdrop looks equivalent and is not: a wheel gesture is
// composited off the main thread, so the assignment lands frames late and the
// caret visibly flies ahead of the text it belongs to.
//
// Mirroring an offset is only correct while both layers can reach it, so the
// geometry underneath is asserted here alongside the visible outcome: the
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
// caret after a final newline; `pre-wrap` collapses one), and one wrap width
// across all three layers (only the textarea scrolls, so only it can lose
// width to a scrollbar that consumes layout space). Either breaks the extent
// equality, and an unreachable offset clamps the glyphs below the caret.
// That failure is what the same-task measurement below pins. Every metric here
// is read through the caret's own coordinate frame — where the textarea puts
// line n — against where the backdrop paints line n, because that difference is
// the defect a user sees, and it is the one number a mirror between two boxes
// cannot hold at zero.
//
// Only a real engine can show this. Scrolling is layout: jsdom reports
// Only a real engine can show any of this. Scrolling is layout: jsdom reports
// `scrollHeight === clientHeight` for every element and never scrolls one, so
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx has
// to stub both offsets and can only prove the mirroring code path runs. What is
// asserted here instead is the user-visible fact that path exists for — after
// scrolling to the end of a long draft, the LAST line is the one on screen —
// measured with a DOM Range over the backdrop's own text.
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can
// only assert that one scrollport contains both layers.
//
// Zero model calls: a fresh workspace's blank session already carries a live
// composer, and the scenario only types into it. A stray stream would fail loud
@@ -49,10 +44,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
/**
* Committed golden of the composer's two-layer scroll geometry. The change
* alters no DOM and no accessible name, so the aria goldens the other scenarios
* commit are byte-identical with and without it; this records the relations
* instead, which makes a shift in the cap or in the layer coupling a reviewable
* diff rather than an assertion someone has to reconstruct.
* alters no accessible name, so the aria goldens the other scenarios commit are
* byte-identical with and without it; this records the relations instead, which
* makes a shift in the cap or in the layer coupling a reviewable diff rather
* than an assertion someone has to reconstruct.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -69,41 +64,54 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
}).join('\n')
/**
* A draft ending in a newline: the shape whose layer extents diverge without
* the backdrop's trailing-line sentinel. A textarea reserves a line box for the
* caret after a final newline; `white-space: pre-wrap` collapses a text node's
* trailing newline and generates none, so the backdrop would come out exactly
* one line shorter and the mirrored offset would clamp a line above the caret.
* A draft ending in a newline: the shape where the two layers reserve their
* final line box on different terms. A textarea keeps one for the caret after a
* final newline; `white-space: pre-wrap` collapses a text node's trailing
* newline and generates none. The hidden auto-grow mirror carries the newline
* and so decides the height for both, which is why the backdrop needs no
* padding of its own — but only a draft of this shape can show it.
*/
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
/** The composer's two text layers as the browser lays them out. */
/** The composer's text layers as the browser lays them out. */
interface ComposerMetrics {
/** True when the draft is taller than the capped box — the situation under test. */
overflows: boolean
/** Visible height of the textarea's content box: the cap in pixels. */
/** Visible height of the scrollport's content box: the cap in pixels. */
clientHeight: number
/** Whole lines that fit in the visible box, at the composer's own line-height. */
visibleLines: number
/** The textarea's scroll offset, which the caret and the selection follow. */
inputScrollTop: number
/** The backdrop's scroll offset, which every visible glyph follows. */
backdropScrollTop: number
/** True when the two layers agree — the coupling this scenario exists for. */
layersAgree: boolean
/** The composer's one scroll offset, which the caret and the glyphs both follow. */
scrollTop: number
/** Furthest that offset can go. */
scrollMax: number
/**
* Scrollable overflow the textarea holds on its own — 0, or a second offset
* exists that nothing keeps equal to this one.
*/
inputScrollable: number
/**
* Distance between where the caret sits for a draft line and where the
* backdrop paints that line, in pixels. A fixed value (the difference between
* a line box's top and its glyph box's) is alignment; a value that CHANGES
* with the scroll offset is the defect — the words trailing the caret.
*/
caretGlyphGap: number
/**
* How much that gap moves when the offset changes inside a single task: 0
* here, because one box carries both layers. Assigning one box's offset to
* another cannot be 0 — a scroll event is dispatched after the task that
* moved the box, so between the two there is a frame with the caret at the
* new offset and the glyphs at the old one.
*/
gapShiftOnScroll: number
/**
* Top of the LAST draft line relative to the visible box's top, in pixels: at
* most `clientHeight` when that line is on screen. This is the reported
* symptom as a number — with the layers uncoupled the backdrop stays at offset
* 0, so the last line sits a full draft-height below the box.
* most `clientHeight` when that line is on screen.
*/
lastLineOffset: number
/** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
firstLineOffset: number
/** Furthest the textarea can scroll. */
inputMax: number
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
backdropMax: number
/** Content width the textarea wraps at. */
inputWrapWidth: number
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
@@ -113,14 +121,16 @@ interface ComposerMetrics {
}
/**
* Measure both composer layers in the page.
* Measure the composer's layers in the page, in the caret's coordinate frame.
* @param page - the page under test.
* @returns the two layers' offsets and where the draft's first and last lines sit.
* @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit.
*/
function measureComposer(page: Page): Promise<ComposerMetrics> {
return page.evaluate(({ first, last }) => {
const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
if (input === null) throw new Error('no live composer textarea in the DOM')
const scroll = input.closest<HTMLElement>('[data-input-scroll]')
if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
// The hidden auto-grow mirror: the textarea's next sibling, and the layer
@@ -128,47 +138,50 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
// two that carry glyphs.
const mirror = input.nextElementSibling
if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
const box = input.getBoundingClientRect()
// The draft carries no chips or claim token, so the decoration walk emits it
// as one text node — the backdrop's first, ahead of the trailing-line
// sentinel React renders as a second one. Both markers live in that first
// node, which is what the Range below needs.
// as a single text node, which is what the Range below needs.
const text = backdrop.firstChild
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
const offsetOf = (marker: string): number => {
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
/** Where the backdrop paints the line holding `marker`, in viewport coordinates. */
const glyphTop = (marker: string): number => {
const at = text.data.indexOf(marker)
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
const range = document.createRange()
range.setStart(text, at)
range.setEnd(text, at + marker.length)
return range.getBoundingClientRect().top - box.top
return range.getBoundingClientRect().top
}
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
// Each layer's own maximum, probed by asking for an impossible offset and
// reading back what it clamped to, then restored. Reading scrollHeight -
// clientHeight instead would compute the maximum rather than observe it.
const restore = input.scrollTop
const restoreBackdrop = backdrop.scrollTop
input.scrollTop = 1e7
backdrop.scrollTop = 1e7
const inputMax = input.scrollTop
const backdropMax = backdrop.scrollTop
input.scrollTop = restore
backdrop.scrollTop = restoreBackdrop
const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop)
// Where the CARET sits on the draft's first line: the textarea lays its own
// (transparent) glyphs out from its border box, shifted by any offset it
// holds itself. Reading the caret's frame this way rather than the
// scrollport's is what makes the gap the user-visible quantity — it stays
// honest if the textarea ever starts scrolling on its own again.
const gap = (): number =>
Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first))
// The same-task probe: move the offset and re-read the gap before the task
// ends, which is before any scroll event could have run a listener.
const before = gap()
const restore = scroll.scrollTop
scroll.scrollTop = restore === 0 ? 120 : 0
const gapShiftOnScroll = Math.abs(gap() - before)
scroll.scrollTop = restore
const box = scroll.getBoundingClientRect()
return {
inputMax,
backdropMax,
inputWrapWidth: input.clientWidth,
backdropWrapWidth: backdrop.clientWidth,
mirrorWrapWidth: mirror.clientWidth,
overflows: input.scrollHeight > input.clientHeight,
clientHeight: input.clientHeight,
visibleLines: Math.floor(input.clientHeight / lineHeight),
inputScrollTop: input.scrollTop,
backdropScrollTop: backdrop.scrollTop,
layersAgree: input.scrollTop === backdrop.scrollTop,
lastLineOffset: offsetOf(last),
firstLineOffset: offsetOf(first),
overflows: scroll.scrollHeight > scroll.clientHeight,
clientHeight: scroll.clientHeight,
visibleLines: Math.floor(scroll.clientHeight / lineHeight),
scrollTop: scroll.scrollTop,
scrollMax: scroll.scrollHeight - scroll.clientHeight,
inputScrollable: input.scrollHeight - input.clientHeight,
caretGlyphGap: before,
gapShiftOnScroll,
lastLineOffset: glyphTop(last) - box.top,
firstLineOffset: glyphTop(first) - box.top,
}
}, { first: FIRST_MARKER, last: LAST_MARKER })
}
@@ -179,43 +192,56 @@ 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 layer agreement,
* and which lines are on screen, each a comparison that survives any layout
* keeping the coupling.
* platform, not the change. 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.
* @param bottom - metrics with the draft scrolled to its end.
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
* @param pasted - metrics right after a long block was pasted at the draft's end.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string {
function renderGeometry(
top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
): string {
return [
'# Composer draft scrolling (14-line cap, two text layers)',
'# Composer draft scrolling (14-line cap, two text layers, one scrollport)',
'',
'## At the start of the draft',
'',
`- draft overflows the capped box: ${String(top.overflows)}`,
`- visible lines: ${String(top.visibleLines)}`,
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
`- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`,
`- all three layers wrap at one width: ${String(
top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
)}`,
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
`- glyph layer tracks it: ${String(top.layersAgree)}`,
`- scroll offset: ${String(top.scrollTop)}px`,
`- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`,
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
`- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
'',
'## Scrolled to the end of the draft',
'',
`- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
`- glyph layer tracks it: ${String(bottom.layersAgree)}`,
`- offset moved: ${String(bottom.scrollTop > 0)}`,
`- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`,
`- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`,
`- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
`- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
'',
'## Draft ending in a newline, scrolled to the end',
'',
`- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
`- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
`- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
`- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`,
`- the draft's own last line is on screen: ${String(
trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
)}`,
'',
'## Right after pasting a long block at the end',
'',
`- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
`- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`,
`- the pasted block's last line is on screen: ${String(
pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
)}`,
].join('\n').trimEnd()
}
@@ -251,17 +277,16 @@ describe('web e2e: composer draft scrolling', () => {
// case below.
await page.locator('textarea:enabled').first().hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
const metrics = await measureComposer(page)
// The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
// 14 x 24px lines). The count, not the pixels: it is the figma constant and
// survives a device-pixel-ratio change.
expect(metrics.visibleLines).toBe(14)
// Resting state: the draft's head is what a 40-line draft shows, and its
// tail is far below the box. Both layers sit at the origin, which is why the
// uncoupled build looks correct until something scrolls.
expect(metrics.inputScrollTop).toBe(0)
expect(metrics.layersAgree).toBe(true)
// One scrolling box: the textarea is as tall as the draft, so there is no
// second offset for the caret to hold while the glyphs hold another.
expect(metrics.inputScrollable).toBe(0)
expect(metrics.scrollTop).toBe(0)
expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
@@ -270,19 +295,12 @@ describe('web e2e: composer draft scrolling', () => {
it('lays out all three text layers at one wrap width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
// The premise under the mirror, asserted rather than assumed. Only .input
// scrolls, so only .input can lose content width to a scrollbar that
// consumes layout space; a narrower .input wraps a long draft onto more
// lines, ends up taller, and its larger maximum makes the mirrored offset
// clamp below the caret. Measured on a standalone harness, an 8px width
// difference is worth 2 to 5 lines on a wrap-sensitive draft.
//
// This holds on the lane's engine and is what a regression would break —
// it is NOT vacuous: measured on the same app, WebKit reports 768 against
// 776 here, which is the divergence the Agent Note records as a
// pre-existing, engine-specific limitation. The mirror is unaffected there
// today because the extents still agree; this assertion is what would
// notice if the lane's engine ever moved into the same state.
// 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.
const metrics = await measureComposer(page)
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
// The mirror decides the box height, so it belongs in the same equality —
@@ -292,66 +310,112 @@ describe('web e2e: composer draft scrolling', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('the glyphs cannot lag the caret: one task moves both', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag'))
// 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
// 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)
expect(metrics.gapShiftOnScroll).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
const input = page.locator('textarea:enabled').first()
await input.hover()
// One delta past the whole draft: the textarea clamps at its own end, and
// the wheel-chaining handler leaves it native because the box is not yet at
// its edge when the gesture starts (the chaining itself is owned by the
// unit spec).
const resting = (await measureComposer(page)).caretGlyphGap
// One delta past the whole draft: the box clamps at its own end, and the
// wheel-chaining handler leaves it native because the box is not yet at its
// edge when the gesture starts (the chaining itself is owned by the unit spec).
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const metrics = await measureComposer(page)
// The coupling, stated directly.
expect(metrics.layersAgree).toBe(true)
// The caret is still on its own glyphs after the gesture.
expect(metrics.caretGlyphGap).toBe(resting)
// The reported symptom, stated as what the user sees: the end of the draft
// is on screen and its beginning is not. On the uncoupled build the glyph
// layer stays at offset 0, so `lastLineOffset` is still a full draft below
// the box and `firstLineOffset` is still 0 — the text never moved.
// is on screen and its beginning is not.
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
expect(metrics.firstLineOffset).toBeLessThan(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('typing at the end of a scrolled draft keeps the layers together', async () => {
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. Typing at the caret — parked at the draft's
// end by the wheel gesture — scrolls it into view, which is a `scroll` like
// any other; this pins that an edit is not a separate case needing its own
// mirror, which is why one listener is the whole implementation.
// 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.
const input = page.locator('textarea:enabled').first()
await input.press('End')
await input.hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
await input.pressSequentially(' tail')
const metrics = await measureComposer(page)
expect(metrics.layersAgree).toBe(true)
expect(metrics.scrollTop).toBeGreaterThan(0)
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('pasting a long block scrolls to the caret it leaves at the end', async () => {
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.
const input = page.locator('textarea:enabled').first()
await input.fill('one short line')
await input.press('End')
// A real `paste` event carrying real clipboard data, dispatched at the
// textarea: the same event a Cmd-V delivers, and it runs the same handler.
await input.evaluate((el, text) => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
// Ending in a newline is the shape the engines disagree on: the caret
// lands on a line with nothing on it, where chromium reports no client
// rects at all for the collapsed position.
}, `\n${DRAFT}\n`)
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
// The restore lands one frame after the machine commits the draft, so the
// box overflows before it moves; waiting on the offset is waiting for the
// behavior itself, and its absence fails this poll.
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
const metrics = await measureComposer(page)
// The caret is at the end of what was pasted, so the draft's last line is
// what has to be on screen.
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
expect(metrics.gapShiftOnScroll).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
// The layers reserve a final line box on different terms, so this shape is
// the one that separates equal extents from a mirror that clamps early.
// the one that separates a height every layer agrees on from a box measured
// one line short of the caret's own last position.
const input = page.locator('textarea:enabled').first()
await input.fill(DRAFT_TRAILING_NEWLINE)
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
const extents = await measureComposer(page)
// The invariant the sentinel exists for. Without it the textarea measured
// 652 against the backdrop's 628 — one 24px line apart.
expect(extents.backdropMax).toBe(extents.inputMax)
await input.hover()
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
return m.scrollTop === m.scrollMax
}, { timeout: 10_000 }).toBe(true)
const bottom = await measureComposer(page)
// At the very bottom the glyphs are level with the caret, not a line behind.
expect(bottom.layersAgree).toBe(true)
// At the very bottom the glyphs are level with the caret, and the draft's
// own last line — the one before the empty final line — is on screen.
expect(bottom.gapShiftOnScroll).toBe(0)
expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
expect(tripwire.pageErrors).toEqual([])
@@ -365,11 +429,11 @@ describe('web e2e: composer draft scrolling', () => {
await input.fill(DRAFT)
await input.hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
const top = await measureComposer(page)
await input.hover()
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const bottom = await measureComposer(page)
await input.fill(DRAFT_TRAILING_NEWLINE)
@@ -377,10 +441,25 @@ describe('web e2e: composer draft scrolling', () => {
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
return m.scrollTop === m.scrollMax
}, { timeout: 10_000 }).toBe(true)
const trailingNewline = await measureComposer(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
// The paste path, measured the way a user meets it: a short draft, the
// caret at its end, one long block pasted in.
await input.fill('one short line')
await input.press('End')
await input.evaluate((el, text) => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
// The ordinary shape — not ending in a newline — so the collapsed branch
// of the reveal keeps a real engine under it; the case above owns the
// after-newline branch.
}, `\n${DRAFT}`)
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
const pasted = await measureComposer(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)

View File

@@ -0,0 +1,420 @@
// Web e2e scenario: the input card holds one horizontal position across the
// Chat and Trajectory tabs.
//
// The composer seat is the same node in both tabs, but it measures itself
// against a different edge in each (see
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
// scroller's content box — the box a space-consuming scrollbar shortens. A view
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
// Trajectory declares and which moves the column's own scrolling into the view)
// 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.
//
// 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
// can assert the declarations exist but not that the two states land in the same
// place. What is asserted here is the user-visible fact — the card does not move
// — measured as the distance between the two tabs' card rectangles.
//
// 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
// 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`
// 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
// reported symptom as a number: the card moves 4px, half the 8px band, on each
// edge.
//
// Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
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 { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
/**
* Committed golden of where the input card sits in each tab, at a wide viewport
* (card at its width cap) and a narrow one (card shrinking with the column).
*
* Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture
* 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.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
const FIXTURE = createChatScrollFixture({
markerPrefix: 'TAB_GEOMETRY',
title: 'COMPOSER_TAB_GEOMETRY long session',
turns: 24,
})
const SEED_ID = 'composer-tab-geometry-web-e2e'
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
const NARROW_VIEWPORT = { width: 800, height: 1000 }
/**
* Resize to one measurement viewport after the responsive sidebar and center
* column finish their track transition.
* @param page - the page under test.
* @param viewport - the viewport dimensions to apply.
* @param sidebarCollapsed - the sidebar state expected at this width.
*/
async function setMeasuredViewport(
page: Page,
viewport: { width: number; height: number },
sidebarCollapsed: boolean,
): Promise<void> {
await page.setViewportSize(viewport)
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
state: sidebarCollapsed ? 'attached' : 'detached',
timeout: 10_000,
})
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
const deadline = performance.now() + 5_000
let previous = host.getBoundingClientRect().width
let stableFrames = 0
while (performance.now() < deadline) {
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const current = host.getBoundingClientRect().width
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
if (stableFrames >= 3) return
previous = current
}
throw new Error('conversation width did not settle after the viewport changed')
})
}
/**
* 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.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
`
/** The column scroller and the input card as the browser lays them out, in one tab. */
interface TabMetrics {
/** Resolved `scrollbar-gutter` on the column's scroller. */
gutter: string
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
overflowX: string
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
overflowY: string
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** True when the column's scroller actually scrolls — only Chat does. */
scrolls: boolean
/** Left edge of the input card in viewport coordinates. */
cardLeft: number
/** Right edge of the input card. */
cardRight: number
/** Width of the input card, capped at the composer card max width. */
cardWidth: number
}
/** One tab's metrics beside the other's, plus the distances between them. */
interface TabComparison {
chat: TabMetrics
trajectory: TabMetrics
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
leftShift: number
/** Distance between the two tabs' card right edges. */
rightShift: number
/** Difference between the two tabs' card widths. */
widthShift: number
}
/**
* Measure the column scroller and the input card in the tab currently shown.
* @param page - the page under test.
* @returns the scroller's resolved overflow style and the card's rectangle.
*/
function measureTab(page: Page): Promise<TabMetrics> {
return page.evaluate(() => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
if (host === null) throw new Error('conversation column scroller not in the DOM')
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
if (card === null) throw new Error('no input card inside the composer seat')
const style = getComputedStyle(host)
const hostRect = host.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
return {
gutter: style.scrollbarGutter,
overflowX: style.overflowX,
overflowY: style.overflowY,
band: hostRect.width - host.clientWidth,
scrolls: host.scrollHeight > host.clientHeight,
cardLeft: cardRect.left,
cardRight: cardRect.right,
cardWidth: cardRect.width,
}
})
}
/**
* Show one tab and wait for the view that owns it to be laid out.
* @param page - the page under test.
* @param tab - the tab to show.
*/
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
await page.getByRole('tab', { name: tab, exact: true }).click()
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
// Both measurements are taken after a paint, so a rectangle read mid-transition
// cannot be reported as a shift the cascade did not cause.
await page.evaluate(() => new Promise<void>((settle) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
}))
}
/**
* Measure both tabs and the distances between them, leaving Chat shown.
* @param page - the page under test.
* @returns each tab's metrics and the card's displacement between them.
*/
async function compareTabs(page: Page): Promise<TabComparison> {
await showTab(page, 'Chat')
const chat = await measureTab(page)
await showTab(page, 'Trajectory')
const trajectory = await measureTab(page)
await showTab(page, 'Chat')
return {
chat,
trajectory,
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
}
}
/**
* Run the pre-fix 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.
*/
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
const style = document.createElement('style')
style.id = id
style.textContent = css
document.head.append(style)
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
try {
return await compareTabs(page)
} finally {
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
}
}
/**
* Open the seeded session from the sidebar search.
*
* Cold summaries carry the temp workspace's basename, so the persisted first
* message is the stable identity to search for, and the query itself drives the
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
* is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
for (;;) {
if (await results.count() === 1) break
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
await page.waitForTimeout(200)
}
await results.click()
}
/**
* Render the golden body.
* @param wide - comparison at the viewport where the card sits at its width cap.
* @param narrow - comparison at the viewport where the card shrinks with the column.
* @param control - comparison at the wide viewport with the reservation removed.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
const section = (name: string, comparison: TabComparison): string[] => [
`## ${name}`,
'',
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
`- Chat reserved band: ${String(comparison.chat.band)}px`,
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
'',
]
return [
'# Input card position across the Chat and Trajectory tabs',
'',
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
...section('Wide viewport, reservation removed in the page (control)', control),
].join('\n').trimEnd()
}
describe('web e2e: input card position across view tabs', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, FIXTURE.log, SEED_ID)
// Scrollbars must take layout space here or the scenario proves nothing;
// see the file header for the measurement behind dropping this argument.
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession(page)
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
.waitFor({ timeout: 30_000 })
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// Vacuity guard, in two parts. A transcript that does not overflow gives
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
// would make the tabs agree without the reservation doing anything.
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
// 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')
expect(comparison.trajectory.band).toBe(comparison.chat.band)
// Declared as a scroll container on both axes rather than left to compute:
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
// horizontal axis computes to `auto` beside a scrolling one.
expect(comparison.trajectory.overflowY).toBe('auto')
expect(comparison.trajectory.overflowX).toBe('hidden')
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
expect(comparison.trajectory.scrolls).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place when the tab changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
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.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const capped = await measureTab(page)
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
// 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.
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('moves the card again once the reservation is removed in the page', async () => {
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.
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.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed tab geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const wide = await compareTabs(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const narrow = await compareTabs(page)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const control = await compareTabsWithoutReservation(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The seeded session is generated in-process, so the geometry golden is the
// whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})

View File

@@ -29,9 +29,7 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
(event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
)
const reason = turnEnd?.data.reason
const reasonSummary = reason?.kind === 'error'
? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status }
: { kind: reason?.kind }
const reasonSummary = { kind: reason?.kind }
expect(reasonSummary).toEqual({ kind: 'completed' })
const calls = events.filter(

View File

@@ -0,0 +1,71 @@
// Keyless assembled-browser coverage for the goal bar over the shipped Web
// bundles and FixtureApiClient wire. The command creates a real projected
// goal in the fixture session; the golden pins the active strip, while the
// clear gesture proves the acknowledged tombstone leaves neither stale chrome
// nor a duplicate-mutation error.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-bar', import.meta.url))
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: goal bar clear convergence', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('renders one active goal and clears it without exposing a stale error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
// Startup reuses the fixture workspace's blank session, keeping this
// command independent of alpha's running replay and pending question.
const input = page.getByPlaceholder('Describe what you want to build')
await input.waitFor({ timeout: 10_000 })
await input.fill('/goal guard rapid clear clicks')
await input.press('Enter')
const bar = page.locator('[data-goal-bar]')
await bar.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
const clear = bar.getByRole('button', { name: 'Clear goal' })
await clear.evaluate((button) => {
const control = button as HTMLButtonElement
control.click()
control.click()
})
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
expect(await page.getByText(/no current goal/iu).count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md'])
})
})

View File

@@ -0,0 +1,5 @@
# The client-side FixtureApiClient intentionally rejects settings writes, so
# this goal-only scenario omits the durable welcome step that would otherwise
# cover the page. Onboarding owns separate assembled-browser coverage.
- id: ui-settings-general
disabled: true

View File

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

View File

@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim.
@@ -33,6 +34,7 @@ const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
const REPLAY_PACE_MS = 100
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
let scaffold: WebScaffold
@@ -42,7 +44,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -82,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
expect(Math.abs(
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
)).toBeLessThan(1)
await input.fill('/cpt')
await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
'compactCompact older conversation history',
])
const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
await input.fill('')
await expect.poll(() => menu.count()).toBe(0)
})
@@ -103,6 +111,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await input.press('Enter')
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`.
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)
const planStyle = await planButton.evaluate((element) => {
@@ -158,8 +170,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const observeTurn = async () => {
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
try {
await input.press('Enter')
if (MODE !== 'record') {
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
await expect.poll(async () => await liveTail.evaluate(element => (
element.scrollWidth > element.clientWidth
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
)), { timeout: 10_000, interval: 10 }).toBe(true)
}
return await settled
} finally {
if (MODE !== 'record') await page.setViewportSize(originalViewport)
}
}
const sessionId = await observeTurn()
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
@@ -237,7 +265,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
])
})
})

View File

@@ -192,9 +192,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const { settled } = await sendPrompt()
await settled
await page.getByRole('tab', { name: 'Trajectory' }).click()
// The boundary marker row itself is a 0-height hairline except at the
// table tail; the marker button is absolutely positioned and stays
// visible, so wait on it directly.
const tailRequest = page.locator('tr[data-request-only="true"]').last()
await tailRequest.waitFor({ timeout: 10_000 })
const requestMarker = tailRequest.getByRole('button', { name: /Request #/ })
await requestMarker.waitFor({ timeout: 10_000 })
const markerWithinTable = await requestMarker.evaluate((element) => {
const marker = element.getBoundingClientRect()

View File

@@ -0,0 +1,132 @@
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 { createMessage, 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 {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-cjk-strong', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-cjk-strong/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-cjk-strong-web-e2e'
const DONE = 'CJK_STRONG_DONE'
const CASES = [
['**注意:**内容', '注意:', '注意:内容'],
['**Notice:**内容', 'Notice:', 'Notice:内容'],
['**事件中间件waterfall**实现', '事件中间件waterfall', '事件中间件waterfall实现'],
['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'],
['**句号。**后续', '句号。', '句号。后续'],
['**Period.**后续', 'Period.', 'Period.后续'],
['**提醒!**继续', '提醒!', '提醒!继续'],
['**Warning!**继续', 'Warning!', 'Warning!继续'],
] as const
/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */
function markdownFixture(): string {
const session = Session.create(SessionId('markdown-cjk-strong-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: 'Render adjacent CJK strong emphasis.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'CJK strong emphasis',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## CJK strong emphasis',
'',
...CASES.flatMap(([markdown]) => [markdown, '']),
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
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: CJK-adjacent Markdown strong emphasis', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, markdownFixture(), 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')('renders punctuation-terminated strong spans before adjacent CJK text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-cjk-strong'))
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)
const strong = page.locator('[class*="markdown"] strong')
await expect.poll(() => strong.count(), { timeout: 10_000 }).toBe(CASES.length)
expect(await strong.allTextContents()).toEqual(CASES.map(([, expected]) => expected))
for (const [, , paragraph] of CASES) {
expect(await page.getByText(paragraph, { exact: true }).count()).toBe(1)
}
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -0,0 +1,210 @@
// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session
// assembled through the Session API is seeded cold into the real web
// composition, then a separate image origin proves that the browser receives
// a real network image while local-path Markdown remains inert alt text.
import { createServer, type Server } from 'node:http'
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 { createMessage, 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 {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-images-web-e2e'
const REMOTE_ALT = 'Remote test image'
const LOCAL_ALT = 'Local test image'
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
)
interface ImageOrigin {
server: Server
url: string
requests: Array<{ path: string | undefined; referer: string | undefined }>
}
/** Start the deterministic remote image origin used by this browser scenario. */
async function startImageOrigin(): Promise<ImageOrigin> {
const requests: ImageOrigin['requests'] = []
const server = createServer((request, response) => {
requests.push({ path: request.url, referer: request.headers.referer })
response.writeHead(200, {
'cache-control': 'no-store',
'content-length': PNG.length,
'content-type': 'image/png',
})
response.end(PNG)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('image origin did not expose an IP socket')
}
return {
server,
url: `http://127.0.0.1:${String(address.port)}/image.png`,
requests,
}
}
/** Stop one image origin after the browser and host release their requests. */
async function stopServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
}
/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
function markdownImageFixture(remoteUrl: string): string {
const session = Session.create(SessionId('markdown-image-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: 'Show the Markdown image policy.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Markdown image policy',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## Markdown images',
'',
`![${REMOTE_ALT}](${remoteUrl})`,
'',
`![${LOCAL_ALT}](./local-image.png)`,
'',
'REMOTE_IMAGE_DONE',
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const header = {
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}
return [
JSON.stringify(header),
// Spaced event times, exactly as the sibling markdown fixtures pin them:
// the stats line renders its LLM segment only while the step's measured
// milliseconds exceed zero, so a fixture that leaves the times unset lets
// the replay's own speed decide whether the golden matches.
...session.events.map(event => JSON.stringify({
...event,
time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
describe('web e2e: remote Markdown image rendering', () => {
let scaffold: WebScaffold
let imageOrigin: ImageOrigin
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
imageOrigin = await startImageOrigin()
scaffold = await launchWebScaffold({})
await seedSession(scaffold, markdownImageFixture(imageOrigin.url), 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()
await stopServer(imageOrigin.server)
})
it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images'))
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('REMOTE_IMAGE_DONE', { exact: true }).count(), {
timeout: 15_000,
}).toBe(1)
const image = page.getByRole('img', { name: REMOTE_ALT })
await image.waitFor({ timeout: 10_000 })
await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), {
timeout: 10_000,
}).toBeGreaterThan(0)
expect(await image.evaluate((element) => {
const computed = getComputedStyle(element)
return {
borderRadius: computed.borderRadius,
decoding: element.getAttribute('decoding'),
loading: element.getAttribute('loading'),
maxWidth: computed.maxWidth,
referrerPolicy: element.getAttribute('referrerpolicy'),
}
})).toEqual({
borderRadius: '8px',
decoding: 'async',
loading: 'lazy',
maxWidth: '100%',
referrerPolicy: 'no-referrer',
})
expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -0,0 +1,142 @@
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 { createMessage, 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 {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-inline-code-links', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-inline-code-links/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-inline-code-links-web-e2e'
const DONE = 'INLINE_CODE_LINK_DONE'
/** Build a settled assistant reply with linkable URL code and inert code controls. */
function markdownFixture(linkUrl: string): string {
const session = Session.create(SessionId('markdown-inline-code-links-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: 'Show the local preview URL.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Inline code links',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## Inline code links',
'',
`Preview: \`${linkUrl}\``,
'',
`Standard: [Open preview](${linkUrl})`,
'',
`Command: \`curl ${linkUrl}\``,
'',
'Unsafe: `javascript:alert(1)`',
'',
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
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: Markdown inline-code links', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let linkUrl: string
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString()
await seedSession(scaffold, markdownFixture(linkUrl), 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')('opens a complete HTTP URL from inline code and leaves other code inert', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-inline-code-links'))
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)
const inlineCodeLink = page.locator('[class*="markdown"] code a')
await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1)
expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl)
expect(await inlineCodeLink.getAttribute('target')).toBe('_blank')
expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer')
await inlineCodeLink.focus()
expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true)
const popupPromise = page.waitForEvent('popup')
await inlineCodeLink.click()
const popup = await popupPromise
await popup.waitForURL(linkUrl, { timeout: 15_000 })
expect(popup.url()).toBe(linkUrl)
await popup.close()
expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0)
expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
.split(linkUrl).join('{{linkUrl}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -0,0 +1,130 @@
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 { createMessage, 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 {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'math-rendering-web-e2e'
const DONE = 'MATH_RENDERING_DONE'
/** Build a settled assistant reply that exercises every supported math delimiter. */
function mathFixture(): string {
const session = Session.create(SessionId('math-rendering-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: 'Render this mathematical proof.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Math rendering',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## Math rendering',
'',
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
'',
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
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: settled Markdown math rendering', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, mathFixture(), 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')('renders the settled reply without KaTeX errors', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering'))
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)
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -1,7 +1,7 @@
// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history
// fixture (zero model calls) and pins the settled conversation aria after the
// user/assistant footers are focus-revealed — the surface package jsdom tests
// cannot substitute for (docs/testing.md snapshot rule).
// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic
// completed-turn-tail fork case (zero model calls) and pins the settled
// conversation aria after the footers are focus-revealed — the surface package
// jsdom tests cannot substitute for (docs/testing.md snapshot rule).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -25,6 +25,48 @@ const MODE = webSnapshotMode()
const SEED_ID = 'message-actions-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.'
const MID_TURN_TEXT = 'I will read both files before answering.'
const SECOND_PROMPT = 'Now give the final answer.'
/**
* Adapt the borrowed recording into response -> tools -> interrupted Think,
* followed by one ordinary completed response. The first response keeps
* copy/clock but is not a legal branch point; the second is the real turn tail.
* @param raw - Recorded seeded-history JSONL.
* @returns A contiguous, closed two-turn fixture.
*/
function completedTailFixture(raw: string): string {
const kept: string[] = []
for (const line of raw.trimEnd().split('\n')) {
const row = JSON.parse(line) as {
type: string
seq?: number
seq0?: number
data?: { content?: unknown[] }
}
const firstSeq = row.seq ?? row.seq0
if (firstSeq !== undefined && firstSeq >= 101) break
if (row.type === 'assistant/message' && row.seq === 64) {
const content = row.data?.content
if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content')
content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT })
kept.push(JSON.stringify(row))
} else {
kept.push(line)
}
}
const tail = [
{ type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } },
{ type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } },
{ type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } },
{ type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' },
{ type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' },
{ type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } },
{ type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } },
]
return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n`
}
describe('web e2e: message IconActions and clocks on settled history', () => {
let scaffold: WebScaffold
@@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
const raw = completedTailFixture(await readFile(SEED, 'utf8'))
expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
@@ -61,24 +103,31 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). User and each turn's last content assistant both
// have copy + branch.
// hover/focus-within). Every durable message footer keeps branch visible,
// but only the final assistant at a completed transcript tail enables it.
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
await copyButtons.first().focus()
await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4)
await expect.poll(
() => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
{ timeout: 5_000 },
).toEqual(['true', 'true', 'true', null])
await branchButtons.first().focus()
await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
.toBe('Available only on the last message of a completed turn')
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
}, 60_000)
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, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Select model', exact: true })
.waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
await page.getByRole('button', { name: 'Copy' }).first().focus()
@@ -89,8 +138,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
// Exercise the assistant action specifically; package coverage pins the
// user action separately at its own event seq.
// The last message action belongs to the completed second-turn assistant.
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),

View File

@@ -58,7 +58,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
// The dormant pi-ai adapter contributes its whole installed catalog; no
// provider is configured yet, so the page is one add button.
const add = dialog.getByRole('button', { name: '+ 添加提供方' })
const add = dialog.getByRole('button', { name: '添加提供方' })
await add.waitFor({ timeout: 10_000 })
// The button enables once the dormant catalog lands in the join.
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)

View File

@@ -9,9 +9,9 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, Page, Response } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
@@ -34,12 +34,46 @@ const SEED_ID = 'navigation-panes-web-e2e'
const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
async function baselineResponse(
page: Page,
method: 'session.list' | 'workspace.list',
): Promise<Response> {
return page.waitForResponse(response => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname === `/api/${method}`
), { timeout: 30_000 })
}
async function assertBaselineSucceeded(response: Response, method: string): Promise<void> {
expect(response.ok(), `${method} baseline HTTP response`).toBe(true)
const body = await response.json() as { result?: { ok?: unknown } }
expect(body.result?.ok, `${method} baseline RPC result`).toBe(true)
}
async function ensureSeedOpen(page: Page): Promise<void> {
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
await result.click()
await chat.waitFor({ timeout: 15_000 })
}
await chat.click()
await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 })
if (await search.inputValue() !== '') {
await search.fill('')
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
}
}
describe('web e2e: navigation & panes over a rich seeded session', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let slotErrors: string[]
let tripwire: ReturnType<typeof watchConsole> = { warnings: [], pageErrors: [] }
let slotErrors: string[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({})
@@ -57,6 +91,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
}, 120_000)
beforeEach(async () => {
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
slotErrors = []
@@ -65,13 +102,52 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
slotErrors.push(message.text())
}
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
// Initial navigation and list ownership settle only after both independent
// RPC baselines succeed; arm before navigation so neither response is missed.
const sessionBaseline = baselineResponse(page, 'session.list')
const workspaceBaseline = baselineResponse(page, 'workspace.list')
const [, sessionResponse, workspaceResponse] = await Promise.all([
page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
sessionBaseline,
workspaceBaseline,
])
await Promise.all([
assertBaselineSucceeded(sessionResponse, 'session.list'),
assertBaselineSucceeded(workspaceResponse, 'workspace.list'),
])
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
// the ready projection replaces.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterEach(async () => {
const failures: unknown[] = []
try {
expect({
pageErrors: tripwire.pageErrors,
slotErrors,
warnings: tripwire.warnings,
}).toEqual({
pageErrors: [],
slotErrors: [],
warnings: [],
})
} catch (error) {
failures.push(error)
}
await page?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed')
})
afterAll(async () => {
await browser?.close()
await scaffold?.close()
const failures: unknown[] = []
await browser?.close().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, 'navigation e2e cleanup failed')
})
it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
@@ -98,6 +174,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// The API baselines can settle before React commits their projection. The
// seeded count is the final user-visible barrier before editing search.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
@@ -132,8 +211,26 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await ensureSeedOpen(page)
await page.getByRole('tab', { name: 'Trajectory' }).click()
await page.waitForTimeout(100)
const overlayLayout = await page.getByRole('table').evaluate((table) => {
const host = table.closest('[data-conversation-scroll]')
const seat = host?.querySelector('[data-composer-seat]') ?? null
const pane = table.parentElement
return {
hostPosition: host === null ? null : getComputedStyle(host).position,
paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX,
paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth,
seatPosition: seat === null ? null : getComputedStyle(seat).position,
}
})
expect(overlayLayout).toEqual({
hostPosition: 'relative',
paneOverflowX: 'hidden',
paneScrollableWidth: 0,
seatPosition: 'absolute',
})
expect({
pageErrors: tripwire.pageErrors,
slotErrors,
@@ -149,6 +246,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.locator('tr[data-kind="tool"]').first().click()
const details = page.getByRole('complementary', { name: 'Event details' })
await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX))
.toBe('hidden')
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
heading: getComputedStyle(heading).backgroundColor,
@@ -158,6 +257,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await page.getByRole('tab', { name: 'Result' }).click()
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
await assistantSpan.hover()
const timingTooltip = page.getByRole('tooltip')
await timingTooltip.waitFor({ timeout: 5_000 })
await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
const assistantTimingStyle = await assistantSpan.evaluate(node => ({
background: getComputedStyle(node).backgroundImage,
ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
}))
expect(assistantTimingStyle.background).toContain('linear-gradient')
expect(assistantTimingStyle.ttft).toMatch(/%$/)
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
@@ -166,7 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
await ensureSeedOpen(page)
await page.getByRole('tab', { name: 'Trajectory' }).click()
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
await plot.waitFor({ timeout: 15_000 })
const before = await page.locator('tr[data-kind]').count()
const box = await plot.boundingBox()
if (box === null) throw new Error('trajectory timeline plot has no layout box')
@@ -183,8 +296,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click()
const bashRow = page.locator('[data-sample="bash-global"]').first()
await ensureSeedOpen(page)
const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 })
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
@@ -194,7 +307,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// The card's own controls are outside the summary row and must not open
// details either — the expanded terminal card is read in place.
await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// Read summaries are host-open file links; they also must not open details.
const fileLink = page.locator('[data-variant="read"] button').first()
@@ -205,15 +318,15 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
await page.getByRole('tab', { name: 'Chat' }).click()
await ensureSeedOpen(page)
// The card is expand-gated behind the whole-row toggle (the unified
// tool-row interaction): open it if a previous case left it collapsed.
// tool-row interaction): open it if this fresh view leaves it collapsed.
// Expanded, the recorded command's own output sits in the message flow,
// derived from the logged call/result presentations alone.
const bashRow = page.locator('[data-sample="bash-global"]').first()
const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 })
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first()
const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
await card.waitFor({ timeout: 15_000 })
// 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
@@ -288,10 +401,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
}, 60_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(slotErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md',

View File

@@ -12,7 +12,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
@@ -22,6 +22,7 @@ import {
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
@@ -160,7 +161,60 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
// Opened here rather than inherited: the credential test reloads the page
// to exercise the welcome step, so nothing carries an open dialog across.
await page.getByRole('button', { name: '设置', exact: true }).click()
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '模型' }).click()
const deepSeek = settings.getByText('DeepSeek', { exact: true }).first()
await deepSeek.waitFor({ timeout: 10_000 })
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
await settings.getByText('自定义设置').click()
await settings.getByRole('button', { name: /删除模型/ }).first().click()
await settings.getByRole('button', { name: '添加模型' }).click()
const customModelId = settings.getByLabel('模型 ID 2')
await customModelId.fill('private-preview')
await settings.getByLabel('显示名称 2').fill('Private Preview')
// Capacities live behind the row's own disclosure, as in the pi-ai form.
await settings.getByRole('button', { name: '容量 2' }).click()
await settings.getByLabel('上下文窗口 2').fill('131072')
await settings.getByLabel('最大输出 token 数 2').fill('64K')
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
await settings.getByRole('button', { name: '保存', exact: true }).click()
await customModelId.waitFor({ state: 'detached', timeout: 15_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('id: deepseek-v4-pro')
expect(document).toContain('id: private-preview')
expect(document).toContain('name: Private Preview')
expect(document).toContain('contextWindow: 131072')
expect(document).toContain('maxTokens: 64000')
expect(document).not.toContain('id: deepseek-v4-flash')
await page.keyboard.press('Escape')
// A connected Workspace is what puts a live composer — and its model
// trigger — on the page; the scaffold boots without one.
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true })
await modelTrigger.waitFor({ timeout: 10_000 })
await modelTrigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0)
await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
await assertFixtureInventory(
SNAPSHOT_DIR,
['missing.expected.md', 'models.expected.md', 'welcome.expected.md'],
)
})
})

View File

@@ -25,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// The waiting golden owns the decision card; the approved golden owns the
// transcript the approval leaves behind — the state the card cannot see.
const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
const MODE = webSnapshotMode()
@@ -82,9 +83,15 @@ describe('web e2e: plan review takeover round trip', () => {
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
await card.getByRole('button', { name: 'Approve' }).click()
@@ -100,6 +107,7 @@ describe('web e2e: plan review takeover round trip', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Card gone; regular input restored.
expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
@@ -108,6 +116,8 @@ describe('web e2e: plan review takeover round trip', () => {
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'review.expected.md', 'approved.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md',
])
})
})

View File

@@ -0,0 +1,103 @@
// Keyless browser regression for pwsh UI parity with bash: a seeded session
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
// api-proxy recomputes presentation views from logged args/result content)
// must render as a bash-shaped terminal card with the parsed exit-status
// 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`
// 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
// with a usable `pwsh` — the lane mounts the pwsh stack through an overlay
// (the shipped tree keeps the bash stack).
import { spawnSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/pwsh-terminal', import.meta.url))
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url))
const PROMPT = 'Run a PowerShell command that fails, then stop.'
const SEED_ID = 'pwsh-terminal-web-e2e'
const MODE = webSnapshotMode()
// The overlay swaps the shipped bash executor for @deepseek-ai/dsh-pwsh-local;
// a host without a usable `pwsh` cannot boot it, so the lane self-skips,
// mirroring the pwshOnly ACP scenarios. The probe follows the executor's own
// resolution (Program Files installs on Windows are found even when bare
// `pwsh` is not on PATH), the same judgment the tool-pwsh tests reuse; record
// mode skips the lane anyway, so the probe stays inert there.
const HAS_PWSH = MODE === 'record' ? false : spawnSync(
resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
{ encoding: 'utf8' },
).status === 0
describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
beforeAll(async () => {
const fixture = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(fixture), 'seed fixture must carry the single drive prompt').toEqual([PROMPT])
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
await seedSession(scaffold, fixture, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('renders the seeded pwsh call as a terminal card with the parsed exit pill', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
// Open the seeded session through content search: the sidebar groups
// sessions by workspace and its row order is world-dependent, while the
// search index covers the seeded log deterministically.
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
await search.fill('Run a PowerShell command')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
await result.click()
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 })
// The tool row is expand-gated: the settled bash-shaped row carries the
// shell-family variant, and the terminal card lives in the expanded body.
const row = page.locator('[data-tool="pwsh"]').first()
await row.waitFor({ timeout: 15_000 })
if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
const card = page.locator('[data-terminal]').first()
await card.waitFor({ timeout: 15_000 })
// The parsed exit pill replaces the `[exit code: 1]` marker in the output
// body — the bash tool's terminal presentation, not the generic fence.
const text = await card.textContent()
expect(text).toContain('exit code 1')
expect(text).toContain('Get-Item : Cannot find path')
expect(text).not.toContain('[exit code: 1]')
const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
// normalizeAria collapses the workspace basename with a '/' split, which
// misses Windows temp paths; collapse it here too (a no-op on POSIX) so
// the golden is platform-independent.
.split(scaffold.workspaceCwd.split(/[\\/]/).pop()!).join('{{workspace}}')
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
}, 60_000)
it('guards the lane fixture inventory', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'terminal-card.expected.md'])
})
})

View File

@@ -0,0 +1,20 @@
# The pwsh terminal-card lane swaps the shipped bash stack for the PowerShell
# twin: the bash executor row is disabled (patches cannot rename a row — `name`
# is a guard) and the pwsh executor + tool are inserted. The permission service
# refuses an unconfined executor by design (presets bundle a sandbox mode), so
# its row is disabled too — this lane renders a seeded session, never a
# permission decision. The seeded scenario renders the logged pwsh call/result
# through the real tools on replay; no command executes, but the composition
# must boot the pwsh executor, so the lane skips on hosts without a usable
# `pwsh`.
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: true
- id: permission
name: '@deepseek-ai/dsh-permission'
disabled: true
- insert:
- id: pwsh-local
name: '@deepseek-ai/dsh-pwsh-local'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'

View File

@@ -23,15 +23,17 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
// Second golden: the answered transcript — the question resolved into its
// tool round trip and the final reply, the state the waiting golden cannot see.
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
// Final golden: the answered transcript — the question resolved into its tool
// round trip and the final reply, the state the composer goldens cannot see.
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only shape that reproduces a
// collapsed row painting its copy outside its own box.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
describe('web e2e: resident question composer round trip', () => {
let scaffold: WebScaffold
@@ -75,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => {
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
if (MODE !== 'record') {
// This golden owns the stable question surface; the answered-state
// golden below owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
// Squeezed card: the option rows are the capped card's scroll content, so
@@ -124,9 +132,17 @@ describe('web e2e: resident question composer round trip', () => {
await page.setViewportSize(original)
}
await composer.getByRole('radio', { name: 'Blue' }).click()
// Submit: Enter on the focused option (the composer's documented submit).
await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
const blue = composer.getByRole('checkbox', { name: 'Blue' })
await blue.click()
const custom = composer.getByRole('textbox')
await custom.fill('Include accessibility notes')
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
}
await custom.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
@@ -135,10 +151,18 @@ describe('web e2e: resident question composer round trip', () => {
}
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents.filter(e => e.type === 'tool/result')
expect(JSON.stringify(results.at(-1))).toContain('Blue')
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
block.type === 'tool-result'
? block.content.filter(item => item.type === 'text').map(item => item.text)
: [],
)).at(-1)
expect(JSON.parse(answerText ?? '')).toEqual({
answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
})
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Composer gone; regular input restored.
expect(await page.locator('[data-question-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
// Golden of the answered transcript: the ask_user_question round trip
// rendered as history (question tool row + DONE), composer takeover gone.
@@ -149,6 +173,12 @@ describe('web e2e: resident question composer round trip', () => {
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
'ui.expected.md',
'sidebar.expected.md',
'composed.expected.md',
'answered.expected.md',
])
})
})

View File

@@ -22,6 +22,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
@@ -31,6 +32,7 @@ const REMOVE = 'Queue item to remove'
const EDIT = 'Queue item to edit'
const EDITED = 'Edited queue item'
const TAIL = 'Queue item preserved after stop'
const WAKE = 'Wake the preserved queue'
/** Durable turn-end classifications observed by the scenario. */
function turnEndReasons(events: readonly SessionEvent[]): string[] {
@@ -62,13 +64,13 @@ describe('web e2e: queue row actions', () => {
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
const readyFile = join(overrideDir, '.hang-ready')
const nextReadyFile = join(overrideDir, '.next-hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
expect(recorded).toHaveLength(1)
const replay: ReplayEntry[] = [
{ kind: 'hang', readyFile },
{ kind: 'hang', readyFile: nextReadyFile },
recorded[0]!,
recorded[0]!,
recorded[0]!,
]
await writeFile(overridePath, JSON.stringify(replay))
@@ -85,7 +87,7 @@ describe('web e2e: queue row actions', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
const input = page.locator('textarea').first()
const settled = scaffold.whenTurnSettled()
const firstSettled = scaffold.whenTurnSettled()
await input.fill(ACTIVE_PROMPT)
await input.press('Enter')
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
@@ -156,26 +158,116 @@ describe('web e2e: queue row actions', () => {
).toBe(2)
await page.getByRole('button', { name: 'Stop generating' }).click()
await expect.poll(() => existsSync(nextReadyFile), { timeout: 15_000 }).toBe(true)
await page.getByText(TAIL, { exact: true }).waitFor()
await firstSettled
await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
.toBe(0)
await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
.toBe(1)
.toBe(2)
const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
const settled = scaffold.whenTurnSettled()
await input.fill(WAKE)
await input.press('Enter')
await settled
await expect.poll(() => turnEndReasons(sessionEvents), { timeout: 15_000 })
.toEqual(['aborted', 'completed', 'completed', 'completed'])
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])).toEqual([ACTIVE_PROMPT, EDITED, TAIL, WAKE])
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
}, 120_000)
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
const readyFile = join(overrideDir, '.hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
const input = page.locator('textarea').first()
const settled = scaffold.whenTurnSettled()
await input.fill('/goal Keep the composer context panels aligned')
await input.press('Enter')
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
const sessions = scaffold.ctx.sessions.list()
expect(sessions).toHaveLength(1)
sessions[0]!.append('todo/write', {
todos: [
{ content: 'Confirm the panel order', status: 'completed' },
{ content: 'Align the panel widths', status: 'in_progress' },
],
})
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
for (const text of ['Layout queue first', 'Layout queue second']) {
await input.fill(text)
await input.press('Enter')
}
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
const layoutSnapshot = await captureStableAria(
page,
'[class*="centerCol"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
const expectAlignedContextPanels = async () => {
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
expect(queuePanelBox).not.toBeNull()
expect(todoBox).not.toBeNull()
expect(goalBox).not.toBeNull()
expect(todoBox!.y).toBeLessThan(goalBox!.y)
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
}
await expectAlignedContextPanels()
await page.setViewportSize({ width: 640, height: 1000 })
await expectAlignedContextPanels()
await page.setViewportSize({ width: 1680, height: 1000 })
await queueHeader.click()
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
await removeButtons.first().click()
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
await removeButtons.first().click()
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Clear goal' }).click()
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents)).toEqual(['aborted', 'aborted', 'completed'])
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user'))
.toHaveLength(3)
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(
SNAPSHOT_DIR,
['collapsed.expected.md', 'editing.expected.md', 'preserved.expected.md', 'ui.expected.md'],
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
)
})
})

View File

@@ -0,0 +1,53 @@
// Trusted non-loopback Web access must not wedge on the loopback-only
// settings API while the mandatory product notice owns the viewport.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('#root', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('advances process-locally and presents the notice again after reload', async () => {
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
await expect.poll(
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
{ timeout: 15_000 },
).toBe(false)
const reloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})

View File

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

View File

@@ -2,8 +2,8 @@
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
// Boots the REAL web composition — the shipped base plus web overlay through
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: normally disables the llm-deepseek row and
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
// harvests fixtures from live session memory), refresh (keyless replay that
@@ -53,9 +53,10 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
/**
@@ -84,11 +85,19 @@ const REPLAY_PROVIDERS = [{
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
}]
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
if (contextWindow === undefined) return REPLAY_PROVIDERS
return REPLAY_PROVIDERS.map(provider => ({
...provider,
models: provider.models.map(model => ({ ...model, contextWindow })),
}))
}
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
mode: WebSnapshotMode
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
/** 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). */
ctx: Context
@@ -120,6 +129,11 @@ export interface LaunchOptions {
* mounts).
*/
replayFixture?: string
/**
* Recorded child logs assigned in child creation order. Each child owns its
* own positional replay cursor across initial and continuation turns.
*/
replayChildFixtures?: string[]
/**
* Optional replay.override.json sidecar (whole-script replacement or
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
@@ -128,6 +142,8 @@ export interface LaunchOptions {
replayOverride?: string
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
paceMs?: number
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
replayContextWindow?: number
/**
* Tool presentation mode patched onto the shipped `tools` row (`code`
* collapses the wire to run_code + the SDK prompt section). Omit for the
@@ -160,6 +176,12 @@ export interface LaunchOptions {
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
/**
* Browse through a trusted non-loopback hostname that the browser resolves
* to loopback (for example `*.localhost`). The test server stays bound to
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
*/
remoteAuthority?: string
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -179,6 +201,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
requireDist()
const mode = webSnapshotMode()
const browserHost = options.remoteAuthority ?? '127.0.0.1'
if (mode === 'record') {
// Both owning vitest configs (web unconditionally, snapshot in record
// mode) load the repo-root .env before this file runs.
@@ -255,7 +278,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
},
...options.remoteAuthority === undefined
? []
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
{ id: 'settings', config: { dshHome: harnessHome } },
{ id: 'credentials', config: { dshHome: harnessHome } },
// The shipped directory-picker row is the -auto chooser, which resolves
@@ -300,6 +329,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// The shipped CLI deliberately has no dependency on this opt-in package.
// Keep the Loader row real without broadening the product installation.
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
@@ -324,8 +354,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
providers: replayProviders(options.replayContextWindow),
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}
@@ -344,30 +375,25 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
return {
harnessHome,
mode,
baseUrl: `http://127.0.0.1:${port}`,
baseUrl: `http://${browserHost}:${port}`,
ctx,
workspaceCwd,
persistenceRoot,
// Barrier stack: the in-process turn/end identifies the session, then
// agent.whenIdle() covers the persistence flush (the idle flip follows
// the flush), and the caller's browser settled-poll comes last because
// host completion strictly precedes render.
// Barrier stack: the in-process turn/end identifies the session, its
// explicit flush makes the transcript durable, and the caller's browser
// settled-poll comes last because host completion strictly precedes render.
whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise<SessionId> {
return new Promise<SessionId>((resolveSettled, reject) => {
const timer = setTimeout(() => {
off()
reject(new Error(`no turn/end within ${timeoutMs}ms`))
}, timeoutMs)
const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
const off = ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'turn/end') return
clearTimeout(timer)
off()
const agent = ctx.agents.get(session.id)
if (agent === undefined) {
reject(new Error(`turn/end for ${session.id} but no live agent`))
return
}
agent.whenIdle().then(() => { resolveSettled(session.id) }, reject)
ctx.sessions.flush(session)
.then(() => { resolveSettled(session.id) }, reject)
})
})
},
@@ -450,15 +476,29 @@ export function fixtureUserPrompts(fixtureText: string): string[] {
* @param id - the seeded session id (stable for deterministic goldens).
* @returns the seeded id.
*/
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
/**
* Realize a recorded seed fixture against one scaffold: substitute the
* `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the
* scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
* price content exactly as the host will fold it) and still pass the result
* through {@link seedSession}.
* @param scaffold - the booted scaffold whose workspace the seed targets.
* @param fixtureText - the committed seed fixture text.
* @param id - the session id the seed is realized for.
* @returns the realized fixture text.
*/
export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, id: string): string {
const realized = fixtureText
.split('{{sessionId}}').join(id)
.split('{{cwd}}').join(scaffold.workspaceCwd)
const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
const rewritten = fixtureCwd === undefined
return fixtureCwd === undefined
? realized
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
const events = parseSessionLog(rewritten)
}
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id))
if (events.length === 0) throw new Error('seed fixture has no events')
const last = events[events.length - 1]!
// An open final turn would be mutated by resume's crash repair on first
@@ -492,8 +532,14 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
}
/**
* Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration
* volatility collapse to stable tokens.
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and
* decode-throughput volatility 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
* wall time the local run took to stream them, so it moves between two runs
* on one machine (measured 69 → 70 tok/s) and swings wildly on a fast replay
* (26333 tok/s for a 3 ms stream).
*/
function normalizeAria(snapshot: string, workspaceCwd: string): string {
// The session heading renders the workspace's basename, not the full
@@ -503,11 +549,22 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
.split(workspaceCwd).join('{{cwd}}')
.split(base).join('{{workspace}}')
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
// The optional space in `\d+m ?\d+s` covers both minute spellings: the
// stats line's compact `2m42s` and the message-chrome template's `2m 42s`.
.replace(
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m ?\d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
duration => duration.startsWith('~') ? duration : '{{duration}}',
)
.replace(
/约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g,
duration => duration.startsWith('约') ? duration : '{{duration}}',
)
.replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
// 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}}')
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
.replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
}
@@ -553,9 +610,9 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string,
}
/**
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
* holds exactly the expected files and every committed JSONL is a scrub
* fixed-point without a run-local browser RPC id.
* Fixture-inventory guard: the scenario directory holds exactly the expected
* files and every committed JSONL is a scrub fixed-point without a run-local
* browser RPC id.
* @param dir - the scenario snapshot directory.
* @param expected - the exact expected file inventory.
*/

View File

@@ -128,11 +128,11 @@ describe('assembled search card', () => {
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
@@ -143,7 +143,7 @@ describe('assembled search card', () => {
// Wait for chat content to reach the fixture's later turns (the bash sample
// is turn 65, the grep card turn 66).
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
// The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
// by default, so wait for the summary row, then expand it to reach the card.

View File

@@ -16,11 +16,14 @@ import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import { join } from 'node:path'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
launchWebScaffold, realizeSeedFixture, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
@@ -41,24 +44,28 @@ const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and
* 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.
* @param raw - the committed seed fixture text.
* @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.
*/
function withCompaction(raw: string): string {
function withCompaction(raw: string, meter: TokenMeterService): string {
const lines = raw.trimEnd().split('\n')
const events = lines.slice(1).map(line => JSON.parse(line) as {
type: string
seq: number
time: number
surfaceOp?: unknown
data?: { turn?: unknown }
data?: { turn?: unknown; message?: unknown; content?: unknown; callId?: unknown; isError?: unknown }
})
const surfaceSeqs = events
.filter(event => event.surfaceOp === 'append'
&& (event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'))
|| event.type === 'tool/result'))
.map(event => event.seq)
const first = surfaceSeqs[0]
const last = surfaceSeqs.at(-1)
@@ -86,8 +93,33 @@ function withCompaction(raw: string): string {
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
at({ type: 'turn/start', data: { turn } })
const startSeq = at({ type: 'compact/start', data: { turn } })
// 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
// exact — pre-identity rows carry bare `content` (the persistence read path
// upgrades them), a current row carries the full `message` envelope.
const priceRow = (row: (typeof events)[number]): number => {
if (row.data?.message !== undefined) {
const message = deriveEventMessage(row as unknown as SessionEvent)
return message === null ? 0 : meter.estimateMessage(message)
}
const content = row.data?.content as ContentBlock[]
if (row.type === 'tool/result') {
return meter.estimateMessage({
content: [{ type: 'tool-result', toolCallId: row.data?.callId, content, isError: row.data?.isError === true }],
} as unknown as Message)
}
// An empty-content assistant message derives no transcript entry.
if (row.type === 'assistant/message' && content.length === 0) return 0
return meter.estimateMessage({ content } as unknown as Message)
}
const shadowedTokenCount = surfaceSeqs.reduce((total, surfaceSeq) => {
const event = events.find(candidate => candidate.seq === surfaceSeq)
if (event === undefined) throw new Error(`seeded-history compaction: shadowed seq ${surfaceSeq} is not in the seed`)
return total + priceRow(event)
}, 0)
const summarySeq = at({
type: 'compact/summary',
data: {
@@ -97,7 +129,7 @@ function withCompaction(raw: string): string {
}],
shadowedRange: { start: first, end: last },
shadowedSeqs: surfaceSeqs,
shadowedTokenCount: 10_000,
shadowedTokenCount,
provider: 'snapshot',
model: 'snapshot-compactor',
},
@@ -138,7 +170,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, withCompaction(raw), SEED_ID)
const meter = scaffold.ctx.get('tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -216,7 +251,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
agent.inject(createUserMessage({
agent.session.append('user/message', createUserMessage({
content: [{
type: 'text',
text: '<system-reminder>\n'
@@ -227,6 +262,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
}],
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [{
action: 'set',
@@ -235,18 +271,20 @@ describe('web e2e: seeded history renders through cold resume', () => {
digest: 'context-injection-browser-snapshot',
}],
},
}))
await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 })
}), { surfaceOp: 'append' })
// The header names the producer the durable source records, so the
// reconciled instruction file is readable without expanding the row.
await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
.waitFor({ timeout: 10_000 })
}, 60_000)
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
await page.getByRole('button', {
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the selected id, but no catalog
// adapter exists to provide its presentation name.
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
// 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 })
.waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
@@ -254,7 +292,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
const disclosure = page.getByRole('button', { name: 'Context injection' })
const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
const collapsedIcon = disclosure.locator('svg').first()
const collapsedIconBox = await collapsedIcon.boundingBox()
@@ -265,6 +303,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const body = page.locator('[data-context-injection-body]')
await body.waitFor({ timeout: 5_000 })
// The instructions form names the file it reconciled above the text, and
// the text keeps the framing the model read rather than a cleaned excerpt.
expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded'])
expect(await body.locator('[data-context-text]').innerText()).toContain('<system-reminder>')
const headerBox = await disclosure.boundingBox()
const bodyBox = await body.boundingBox()
if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable')
@@ -339,39 +381,40 @@ describe('web e2e: seeded history renders through cold resume', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no
// model call, so the settled row renders keylessly over this cold history.
// The row copy is the assertion: `permission · preset workspace-write`,
// The row copy is the assertion: `permission · preset read-only`,
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
// Scoped to the row itself, so unrelated page text that happens to read
// `permission` (a future resident slash menu) cannot satisfy or break it.
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' })
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0)
expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
}, 60_000)
it.skipIf(MODE === 'record')('fits short injected context without a scrollport', async () => {
it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => {
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
agent.inject(createUserMessage({
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Short injected context.' }],
source: { kind: 'plugin', plugin: 'fixture' },
}))
}), { surfaceOp: 'append' })
const disclosures = page.getByRole('button', { name: 'Context injection' })
await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2)
const disclosure = disclosures.nth(1)
const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true })
await disclosure.waitFor({ timeout: 10_000 })
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const body = page.locator('[data-context-injection-body]')
// The instructions row above stays expanded from the geometry case; the
// opaque body is the one without a declared form.
const body = page.locator('[data-context-injection-body]:not([data-context-form])')
const bodyBox = await body.boundingBox()
if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
expect(bodyBox.height).toBeLessThan(141)

View File

@@ -2,8 +2,9 @@
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// and the Language row (settings-scoped localization + persisted dsh.locale),
// plus Permission as the persisted default for subsequently created sessions.
// the Language row (settings-scoped localization + persisted dsh.locale),
// the busy-state Enter preference, plus Permission as the persisted default
// for subsequently created sessions.
// Zero model calls: everything is pure client + persistence state on a blank
// frame, so there is no fixture and a stray stream would fail loud on the
// open llm seam.
@@ -57,9 +58,33 @@ describe('web e2e: settings modal and General preferences', () => {
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
// General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
await openDocument.waitFor({ timeout: 10_000 })
let openRequests = 0
await page.route('**/api/settings.openDocument', async (route) => {
const envelope = route.request().postDataJSON() as {
rpcId: string
payload: Record<string, never>
}
expect(envelope.payload).toEqual({})
openRequests += 1
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
type: 'server-response',
rpcId: envelope.rpcId,
result: { ok: true, value: { opened: true } },
}),
})
})
await openDocument.click()
await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
await page.unroute('**/api/settings.openDocument')
// Golden of the freshly opened dialog (default zh, General active).
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
@@ -82,12 +107,12 @@ describe('web e2e: settings modal and General preferences', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'danger-full-access' })
.toEqual({ preset: 'workspace-write' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
const selector = dialog.getByRole('button', { name: 'Full access' })
const selector = dialog.getByRole('button', { name: 'Workspace Write' })
await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click()
@@ -98,7 +123,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(document).toContain('permission:')
expect(document).toContain('defaultPreset: read-only')
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'danger-full-access' })
.toEqual({ preset: 'workspace-write' })
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
expect(created.events.map(event => [event.type, event.data])).toEqual([
@@ -182,6 +207,32 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('persists the busy-state Enter behavior across reload and restores Queue', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '排队发送' }).click()
await page.getByRole('menuitem', { name: '插话发送' }).click()
await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer')
await page.keyboard.press('Escape')
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '设置', exact: true }).click()
const reloaded = page.getByRole('dialog', { name: '设置' })
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
await reloaded.getByRole('button', { name: '插话发送' }).click()
await page.getByRole('menuitem', { name: '排队发送' }).click()
await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue')
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('switches the settings surface language and persists dsh.locale', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
await page.getByRole('button', { name: '设置', exact: true }).click()

View File

@@ -10,6 +10,7 @@ import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
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 { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/**
@@ -27,13 +28,10 @@ const EXPECTED_TOOLS = [
'edit',
'exit_plan_mode',
'get_goal',
'list_agents',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'send_message',
'skill',
'str_replace_editor',
'subagent',
@@ -49,10 +47,10 @@ const EXPECTED_TOOLS = [
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
* `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED
* ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair
* is always present on every host — asserted as fixed members, not a host
* dependency.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
@@ -63,11 +61,13 @@ afterEach(async () => {
scaffold = undefined
})
it('assembles the shipped Web catalog and keeps its access default', async () => {
it('assembles the shipped Web catalog with the confined access default', async () => {
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name)))
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and
@@ -76,8 +76,7 @@ it('assembles the shipped Web catalog and keeps its access default', async () =>
expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual(
expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]),
)
// The Web surface keeps its shipped access default; the base's confined one
// reaches the TUI. Pinning both keeps a base change from moving Web silently.
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('danger-full-access')
expect(scaffold.ctx.approval.config.policy).toBe('never')
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
expect(scaffold.ctx.approval.config.policy).toBe('ask')
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
}, 120_000)

View File

@@ -44,6 +44,12 @@
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
// the other assertions in its test silenced.
//
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
// to `transparent` while the pointer is outside the column), so every
// measurement below states which pointer position it was taken at: the
// scenario parks the pointer over the sidebar before asserting a colour, and
// the quiet state and its linger get their own test.
//
// Chromium also takes the `::-webkit-scrollbar*` path, not the standard
// properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind
// `@supports not selector(::-webkit-scrollbar)`, which is false here. The
@@ -106,6 +112,10 @@ interface ListMetrics {
overflows: boolean
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** Distance from the scrollbar's right edge to the sidebar edge. */
scrollbarEdgeOffset: number
/** Distance from the first row background's right edge to the sidebar edge. */
rowEdgeInset: number
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
clientRight: number
/** Border-box right edge in viewport coordinates. */
@@ -133,6 +143,8 @@ function measureList(page: Page): Promise<ListMetrics> {
if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
// Each indirection variable is resolved through its own throwaway probe
// appended to the list: `var()` substitution then happens where the list
// sits in the cascade, which is the claim, and `color` normalizes whatever
@@ -167,6 +179,9 @@ function measureList(page: Page): Promise<ListMetrics> {
const style = getComputedStyle(list)
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
@@ -177,9 +192,11 @@ function measureList(page: Page): Promise<ListMetrics> {
token: resolve('--dsh-scrollbar-thumb'),
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: list.getBoundingClientRect().width - list.clientWidth,
clientRight: list.getBoundingClientRect().left + list.clientWidth,
borderRight: list.getBoundingClientRect().right,
band: listRect.width - list.clientWidth,
scrollbarEdgeOffset: sidebarEdge - listRect.right,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right,
// 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
@@ -188,11 +205,60 @@ function measureList(page: Page): Promise<ListMetrics> {
// 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 - (list.getBoundingClientRect().right - barWidth)),
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
}
})
}
/**
* Measure only overflow and row inset, which remain observable when every
* session is hidden under a collapsed workspace group.
* @param page - the page under test.
* @returns the list overflow state and first row's trailing inset.
*/
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
}
})
}
/** One palette's readings, taken at both pointer positions. */
interface PaletteMetrics {
/** Everything measured with the pointer over the list, which is when a thumb exists. */
hovered: ListMetrics
/** `--dsh-scrollbar-thumb` with the pointer parked outside the column. */
quietThumb: string
}
/**
* Read one palette at both pointer positions, ending with the pointer back
* over the list so a caller measuring further leaves it revealed.
* @param page - the page under test.
* @returns the palette's quiet thumb and its hovered metrics.
*/
async function measurePalette(page: Page): Promise<PaletteMetrics> {
await pointAt(page, 'away')
// Poll rather than sleep the linger out: the wait is the column's, and a
// fixed sleep would either race it or pad every palette.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
const quietThumb = await resolveThumb(page)
await pointAt(page, 'list')
// Poll the reveal too: the reading below is a colour, and taking it in the
// same tick as the pointer move would race React's flush and land a
// transparent thumb in the golden.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).not.toBe(NO_THUMB)
return { hovered: await measureList(page), quietThumb }
}
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the fix establishes.
@@ -208,20 +274,23 @@ function measureList(page: Page): Promise<ListMetrics> {
* @param dark - metrics measured under the dark palette.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
const palette = (name: string, metrics: ListMetrics): string[] => [
function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [
`## ${name}`,
'',
`- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`,
`- scrollbar-gutter: ${metrics.gutter}`,
`- ::-webkit-scrollbar width: ${metrics.width}`,
`- ::-webkit-scrollbar-track background: ${metrics.track}`,
`- scrollbar-width: ${metrics.standardWidth}`,
`- scrollbar-color: ${metrics.standardColor}`,
`- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
`- --dsh-scrollbar-thumb: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
`- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -235,6 +304,48 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
].join('\n').trimEnd()
}
/**
* Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the
* geometry. Own probe element for the same reason {@link measureList} uses
* one: `getComputedStyle` returns a live declaration.
* @param page - the page under test.
* @returns the resolved thumb colour, serialized as `rgb`/`rgba`.
*/
function resolveThumb(page: Page): Promise<string> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const probe = document.createElement('span')
probe.style.color = 'var(--dsh-scrollbar-thumb)'
list.append(probe)
const value = getComputedStyle(probe).color
probe.remove()
return value
})
}
/** Fully transparent, which is how the quiet column spells "no thumb". */
const NO_THUMB = 'rgba(0, 0, 0, 0)'
/**
* Park the pointer over the session list or outside the sidebar entirely. The
* column reveals its scrollbars from real pointer movement, so a scenario that
* never moves the mouse measures the quiet state whatever it intended to.
* @param page - the page under test.
* @param where - `list` to point at the session list, `away` for the far side
* of the viewport (the conversation column).
*/
async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox()
if (box === null) throw new Error('sidebar session list has no layout box')
const viewport = page.viewportSize()
if (viewport === null) throw new Error('page has no viewport')
const target = where === 'list'
? { x: box.x + box.width / 2, y: box.y + box.height / 2 }
: { x: viewport.width - 5, y: box.y + box.height / 2 }
await page.mouse.move(target.x, target.y)
}
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
@@ -280,6 +391,10 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expandSeededSessions(page)
// Every assertion about a thumb colour needs a drawn thumb, and the column
// only draws one under the pointer; the quiet state is asserted where it is
// the subject rather than left as an ambient condition of the whole file.
await pointAt(page, 'list')
}, 180_000)
afterAll(async () => {
@@ -299,6 +414,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
// drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform.
expect(metrics.band).toBeGreaterThan(0)
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
@@ -317,6 +434,48 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer'))
const revealed = await resolveThumb(page)
expect(revealed).not.toBe(NO_THUMB)
await pointAt(page, 'away')
// The linger, measured as a state rather than a duration: the thumb is
// still drawn on the leave itself, and gone once the window has passed. A
// tighter timing assertion would pin the wall clock of a CI machine.
expect(await resolveThumb(page)).toBe(revealed)
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
// The reservation is unconditional, so nothing moved while the bar was
// hidden — this is what buys `transparent` over hiding the bar itself.
const quiet = await measureList(page)
expect(quiet.gutter).toBe('stable')
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
// 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 })
await page.waitForTimeout(500)
expect(await resolveThumb(page)).toBe(NO_THUMB)
await pointAt(page, 'list')
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the row background inset when overflow disappears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.click()
try {
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
} finally {
await expandSeededSessions(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders the themed thumb through the WebKit path in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
const light = await measureList(page)
@@ -354,9 +513,9 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
it('matches the committed scrollbar geometry golden in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
const light = await measureList(page)
const light = await measurePalette(page)
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measureList(page)
const dark = await measurePalette(page)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
expect(tripwire.pageErrors).toEqual([])

View File

@@ -26,6 +26,8 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
let out = ''
@@ -184,7 +186,7 @@ describe('dsh web keyless CLI smoke', () => {
}
})
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
@@ -194,16 +196,19 @@ describe('dsh web keyless CLI smoke', () => {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
}
let resolveProviderRequest!: (request: NativeProviderRequest) => void
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
resolveProviderRequest = resolve
let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void
const requests: NativeProviderRequest[] = []
const providerRequests = new Promise<NativeProviderRequest[]>((resolve) => {
resolveProviderRequests = resolve
})
const provider = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
const parsed = JSON.parse(body) as NativeProviderRequest
if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed)
if (requests.length === 1) resolveProviderRequests(requests)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
@@ -220,7 +225,7 @@ describe('dsh web keyless CLI smoke', () => {
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
{
cwd: workspace,
env: {
@@ -242,16 +247,22 @@ describe('dsh web keyless CLI smoke', () => {
mode: 'queue',
content: [{ type: 'text', text: 'go' }],
})
const captured = await Promise.race([
providerRequest,
const capturedRequests = await Promise.race([
providerRequests,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
}),
])
expect(captured.messages?.some(message =>
message.role === 'user' && message.content?.includes('<available_skills>'))).toBe(false)
const captured = capturedRequests[0]
if (captured === undefined) {
throw new Error('provider did not receive the workspace projection request')
}
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
.replace('{{webUrl}}', baseUrl)
expect(systemMessage?.content).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(`
{
"content": "<system-reminder>
@@ -588,7 +599,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
// Bash renders through the third-party sample registration. Match that
// exact row: other clickable variants (for example Think disclosure)
// may precede the tool call in document order.
const toolRow = page.locator('[data-sample="bash-global"]')
const toolRow = page.locator('[data-sample="bash"]')
await toolRow.waitFor({ timeout: 120_000 })
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0)

View File

@@ -0,0 +1,33 @@
- banner:
- navigation "Session hierarchy":
- 'button "Run two shell commands: wait" [disabled]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- 'button "Failed Bash Error: tool call aborted" [expanded]':
- img
- text: "Failed Bash Error: tool call aborted"
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted"
- button "Inspect"
- 'button "Failed Bash Error: tool call aborted before dispatch"':
- img
- text: "Failed Bash Error: tool call aborted before dispatch"
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- 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

@@ -1,17 +1,19 @@
- banner:
- 'heading "Using ONE run_code program: run" [level=1]'
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "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. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
@@ -34,13 +36,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "7% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 7% of 128K Cache hit 52% Input 17.2K tok · Output 252 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 52% Input 17.2K tok · Output 252 tok

View File

@@ -1,25 +1,31 @@
# Composer draft scrolling (14-line cap, two text layers)
# Composer draft scrolling (14-line cap, two text layers, one scrollport)
## At the start of the draft
- draft overflows the capped box: true
- visible lines: 14
- both layers share one scroll extent: true
- the textarea holds no scroll offset of its own: true
- all three layers wrap at one width: true
- textarea scroll offset: 0px
- glyph layer tracks it: true
- scroll offset: 0px
- caret and glyphs stay level when the offset changes: true
- first draft line is on screen: true
- last draft line is on screen: false
## Scrolled to the end of the draft
- textarea moved: true
- glyph layer tracks it: true
- offset moved: true
- caret sits on its own glyphs: true
- caret and glyphs stay level when the offset changes: true
- first draft line has scrolled out above: true
- last draft line is on screen: true
## Draft ending in a newline, scrolled to the end
- both layers share one scroll extent: true
- glyph layer tracks the caret: true
- last draft line is on screen: true
- caret sits on its own glyphs: true
- the draft's own last line is on screen: true
## Right after pasting a long block at the end
- the composer scrolled to the caret it left: true
- caret and glyphs stay level when the offset changes: true
- the pasted block's last line is on screen: true

View File

@@ -0,0 +1,37 @@
# Input card position across the Chat and Trajectory tabs
## Wide viewport (1680px, card at its cap)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Narrow viewport (800px, card shrinking with the column)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Wide viewport, reservation removed in the page (control)
- Chat: scrollbar-gutter auto, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
- Trajectory scroller scrolls: false
- Trajectory reserved band: 0px
- input card left edge moves between tabs: 4px
- input card right edge moves between tabs: 4px
- input card width changes between tabs: 0px

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Use only Cordis tools. First" [level=1]
- navigation "Session hierarchy":
- button "Use only Cordis tools. First" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to:":
- img
- img
@@ -49,13 +51,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "13% of context used"
- button "Send message" [disabled]
- text: 1 turns · 4 steps Tool call {{duration}} Context 13% of 128K Cache hit 77% Input 66.5K tok · Output 312 tok
- text: 1 turns · 4 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 77% Input 66.5K tok · Output 312 tok

View File

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

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Use the bash tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img
@@ -29,13 +31,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 99% Input 15.7K tok · Output 111 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok

View File

@@ -0,0 +1,8 @@
- img
- text: Ongoing Goal guard rapid clear clicks
- button "Pause goal":
- img
- button "Edit goal":
- img
- button "Clear goal":
- img

View File

@@ -0,0 +1,3 @@
- listbox "Trigger suggestions":
- text: Commands
- option "compact Compact older conversation history" [selected]

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building
- text: Let's start building Preview
- button "Choose workspace":
- img
- text: workspace
@@ -28,7 +28,8 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- tooltip "Commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building
- text: Let's start building Preview
- button "Choose workspace":
- img
- text: workspace
@@ -28,10 +28,10 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode on, press to turn off": Plan
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: Details

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Reply with the single word" [level=1]
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
@@ -21,13 +23,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 21 tok
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok

View File

@@ -1,28 +1,30 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- text: Stopped
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -1,24 +1,26 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- status:
- text: This turn failedAPI key is invalid
- code: AUTH
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -1,23 +1,25 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- group:
- status: Retried model request (1/2) · {{duration}}
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
@@ -23,13 +25,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 79 tok
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 79 tok

View File

@@ -0,0 +1,52 @@
- banner:
- navigation "Session hierarchy":
- button "CJK strong emphasis" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Render adjacent CJK strong emphasis. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "CJK strong emphasis" [level=2]
- paragraph:
- strong: 注意:
- text: 内容
- paragraph:
- strong: "Notice:"
- text: 内容
- paragraph:
- strong: 事件中间件waterfall
- text: 实现
- paragraph:
- strong: 事件中间件(waterfall)
- text: 实现
- paragraph:
- strong: 句号。
- text: 后续
- paragraph:
- strong: Period.
- text: 后续
- paragraph:
- strong: 提醒!
- text: 继续
- paragraph:
- strong: Warning!
- text: 继续
- paragraph: CJK_STRONG_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -0,0 +1,31 @@
- banner:
- navigation "Session hierarchy":
- button "Markdown image policy" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Show the Markdown image policy. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Markdown images" [level=2]
- paragraph:
- img "Remote test image"
- paragraph: Local test image
- paragraph: REMOTE_IMAGE_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -0,0 +1,43 @@
- banner:
- navigation "Session hierarchy":
- button "Inline code links" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Show the local preview URL. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Inline code links" [level=2]
- paragraph:
- text: "Preview:"
- code:
- link "{{linkUrl}}":
- /url: {{linkUrl}}
- paragraph:
- text: "Standard:"
- link "Open preview":
- /url: {{linkUrl}}
- paragraph:
- text: "Command:"
- code: curl {{linkUrl}}
- paragraph:
- text: "Unsafe:"
- code: javascript:alert(1)
- paragraph: INLINE_CODE_LINK_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -0,0 +1,47 @@
- banner:
- navigation "Session hierarchy":
- button "Math rendering" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Render this mathematical proof. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Math rendering" [level=2]
- paragraph:
- text: Inline dollar
- math: θ
- text: and backslash
- math: 1 5
- text: .
- math: π 4 < θ < π 2
- math: θ ∈ ( π 4 , π 2 ) . (1)
- table:
- rowgroup:
- row "Symbol Value":
- columnheader "Symbol"
- columnheader "Value"
- rowgroup:
- row:
- cell:
- math: θ
- cell:
- math: 1 5
- paragraph: MATH_RENDERING_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok

View File

@@ -1,5 +1,6 @@
- banner:
- heading "Use the read tool twice" [level=1]
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -7,12 +8,19 @@
- button "Copy":
- img
- tooltip "Copy"
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- paragraph: I will read both files before answering.
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Read a.txt":
- img
- img
@@ -27,18 +35,24 @@
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- text: Stopped Now give the final answer. 7/25 {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- paragraph: DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- text: 7/25 {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
- 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

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
@@ -17,4 +18,9 @@
- text: minimax-cn
- button "编辑"
- button "删除"
- button "+ 添加提供方"
- button "添加提供方":
- img
- text: 添加提供方
- button "添加自定义提供方":
- img
- text: 添加自定义提供方

View File

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭

View File

@@ -4,7 +4,8 @@
- button "Collapse calls": Calls
- img
- searchbox "Search trajectory"
- region "Trajectory timeline"
- region "Trajectory timeline":
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
- table:
- rowgroup:
- row "SYSTEM, Initial System Prompt":

View File

@@ -0,0 +1,74 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- button "编辑"
- text: DeepSeek deepseek-official API 密钥
- textbox "API 密钥":
- /placeholder: 已配置——输入新值可替换
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com
- text: 推理强度
- combobox "推理强度":
- option "默认" [selected]
- option "off"
- option "high"
- option "max"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: deepseek-v4-pro
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: DeepSeek-V4-Pro
- button "容量 1":
- img
- button "删除模型 1":
- img
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: private-preview
- textbox "显示名称 2":
- /placeholder: 显示名称
- text: Private Preview
- button "容量 2" [expanded]:
- img
- button "删除模型 2":
- img
- text: 上下文窗口
- textbox "上下文窗口 2":
- /placeholder: 1M
- text: "131072"
- text: 最大输出 token 数
- textbox "最大输出 token 数 2":
- /placeholder: 256K
- text: 64K
- button "添加模型":
- img
- text: 添加模型
- button "取消"
- button "保存"
- button "添加提供方":
- img
- text: 添加提供方
- button "添加自定义提供方":
- img
- text: 添加自定义提供方

View File

@@ -1,18 +1,20 @@
- banner:
- 'heading "Plan a small change: add" [level=1]'
- navigation "Session hierarchy":
- 'button "Plan a small change: add" [disabled]'
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- img
- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."':
- img
- img
@@ -34,13 +36,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "4% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 4% of 128K Cache hit 51% Input 10.2K tok · Output 346 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 10.2K tok · Output 346 tok

View File

@@ -0,0 +1 @@
- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]'

View File

@@ -0,0 +1,19 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747}
{"type":"turn/start","seq":0,"time":1784974200000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784974200001,"data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784974200002,"data":{"title":"Run a PowerShell command","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784974200010,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784974200011,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784974200200,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":6,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Run the failing pwsh command."}}}
{"type":"assistant/chunk","seq":7,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Run the failing pwsh command."}}}}
{"type":"assistant/chunk","seq":8,"time":1784974200300,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":9,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_pwsh_fail_0001","name":"pwsh","argumentsDelta":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}
{"type":"assistant/chunk","seq":10,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}}
{"type":"assistant/chunk","seq":11,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}}}}
{"type":"assistant/chunk","seq":12,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":13,"time":1784974200310,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
{"type":"tool/call","seq":14,"time":1784974200311,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}
{"type":"tool/result","seq":15,"time":1784974200500,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","seq":16,"time":1784974200501,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":17,"time":1784974200501,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,3 @@
- text: Failed {{workspace}} Get-Item missing.txt exit code 1
- button "Copy"
- text: "[stderr] Get-Item : Cannot find path 'missing.txt' because it does not exist."

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Use the ask_user_question tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}"
- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img
@@ -29,13 +31,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "3% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 3% of 128K Cache hit 95% Input 8.6K tok · Output 180 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 95% Input 8.6K tok · Output 180 tok

View File

@@ -0,0 +1,17 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- button "Dismiss all questions":
- img
- group:
- checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer": Include accessibility notes
- button "Previous question" [disabled]:
- img
- text: 1 / 1
- button "Next question" [disabled]:
- img
- status
- button "Skip this question"
- button "Submit"

View File

@@ -1,20 +1,20 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}}
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

View File

@@ -0,0 +1 @@
- treeitem "Waiting for answer Use the ask_user_question tool to now" [selected]

View File

@@ -3,9 +3,9 @@
- heading "Which color do you prefer?" [level=2]
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- group:
- checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer"
- button "Previous question" [disabled]:
- img

View File

@@ -1,24 +1,26 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- button "2 queued messages" [disabled] [expanded]
@@ -22,16 +24,19 @@
- img
- button "Remove queued message":
- img
- button "Steer queued message":
- img
- listitem:
- textbox "Edit queued message": Edited queue item
- button "Save queued message":
- img
- tooltip "Save queued message"
- button "Cancel editing":
- img
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -0,0 +1,39 @@
- banner:
- navigation "Session hierarchy":
- button "workspace" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
- img
- img
- text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
- button "Context injection goal":
- img
- img
- text: Context injection goal
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- region "To-dos":
- button "To-dos 1 completed · 1 in progress"
- img
- text: Ongoing Goal Keep the composer context panels aligned
- button "Pause goal":
- img
- button "Edit goal":
- img
- button "Clear goal":
- img
- button "2 queued messages"
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"

View File

@@ -1,43 +1,51 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- text: Stopped
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Edited queue item {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- paragraph: partial
- status: Deep diving...
- text: {{clock}} Ran for {{duration}}
- button "2 queued messages" [expanded]
- list:
- listitem:
- text: Edited queue item
- button "Edit queued message":
- img
- tooltip "Edit queued message"
- button "Remove queued message":
- img
- button "Steer queued message" [disabled]:
- img
- listitem:
- text: Queue item preserved after stop
- button "Edit queued message":
- img
- button "Remove queued message":
- img
- button "Steer queued message" [disabled]:
- img
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Reply with a one-sentence description" [level=1]
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- list:
@@ -19,12 +21,15 @@
- text: Edited queue item
- button "Edit queued message":
- img
- tooltip "Edit queued message"
- button "Remove queued message":
- img
- button "Steer queued message":
- img
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -7,7 +7,7 @@ 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=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
line=78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
expand=… 其余 4 行
recovery=Found 9 of 42 matches
@@ -22,6 +22,6 @@ 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 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
Line 78: yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)

View File

@@ -1,13 +1,15 @@
- banner:
- heading "Use the read tool twice" [level=1]
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
@@ -31,22 +33,22 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- 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 "Context injection":
- button "Context injection AGENTS.md":
- img
- img
- text: Context injection
- text: Context injection AGENTS.md
- img
- text: permission preset workspace-write
- text: permission preset read-only
- 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
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
- 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

@@ -1,13 +1,15 @@
- banner:
- heading "Use the read tool twice" [level=1]
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
@@ -31,20 +33,20 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- 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 "Context injection":
- button "Context injection AGENTS.md":
- img
- img
- text: Context injection
- text: Context injection AGENTS.md
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
- 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

@@ -7,12 +7,13 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- text: 权限 选择新会话的默认权限模式
- button "Full access":
- text: Full access
- button "Workspace Write":
- text: Workspace Write
- img
- text: 语言
- button "中文":
@@ -28,3 +29,7 @@
- button "跟随系统" [pressed]:
- img
- text: 跟随系统
- text: 繁忙时 Enter 键行为 仅在智能体运行时生效Cmd/Ctrl+Enter 使用另一行为
- button "排队发送":
- text: 排队发送
- img

View File

@@ -2,32 +2,38 @@
## Light palette
- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0)
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(229, 229, 229)
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
- --dsh-scrollbar-thumb, pointer over the list: rgb(229, 229, 229)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(212, 212, 212)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
## Dark palette
- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0)
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(60, 60, 61)
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
- --dsh-scrollbar-thumb, pointer over the list: rgb(84, 85, 87)
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(101, 103, 107)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Use the ask_user_question tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
@@ -22,6 +24,9 @@
- img
- text: Ask question waiting
- status: Deep diving...
- text: "Interjection Interjection: include the word BANANA in your final reply."
- button "Copy":
- img
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]

View File

@@ -15,7 +15,7 @@
{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"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],"surfaceOp":"append"}
{"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}
{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"user/message","seq":91,"time":1785004181867,"data":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Use the ask_user_question tool to" [level=1]
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
@@ -20,7 +22,12 @@
- img
- img
- text: Ask question 1/1 answered
- text: "Interjection: include the word BANANA in your final reply."
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img
@@ -30,13 +37,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 98% Input 15.8K tok · Output 156 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 156 tok

View File

@@ -0,0 +1,2 @@
- tree "Subagent sessions":
- treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
- tree "Subagent sessions":
- treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d
- treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]:
- button "Collapse event-sourcing researcher descendants":
- img
- text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}}
- group:
- treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}}

View File

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

View File

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

View File

@@ -1,17 +1,19 @@
- banner:
- heading "Use web_search to search exactly" [level=1]
- navigation "Session hierarchy":
- button "Use web_search to search exactly" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- button "Context injection":
- text: Available only on the last message of a completed turn
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Search DeepSeek Harness snapshot search":
- img
- img
@@ -21,13 +23,14 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- '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 · 2 steps Tool call {{duration}} Context 0% of 128K Cache hit 0% Input 22 tok · Output 7 tok
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 22 tok · Output 7 tok

View File

@@ -4,7 +4,8 @@
- button "Home"
- img
- button "browse-golden"
- button "Edit path"
- button "Edit path":
- img
- list:
- listitem:
- button "adopted":

View File

@@ -12,6 +12,9 @@
// assembled application can show is that the path a user actually takes
// reaches it: the real selection service, the real client session opening over
// the real /api transport, and a real browser deciding what is painted.
// The initial Workspace pick also records the resident Hero/composer nodes and
// proves that opening the first blank Session fills the strict outlets without
// replacing those nodes.
//
// The round-trip against a loopback host is far too fast to observe, so this
// scenario HOLDS the `session.history` response open at the browser's network
@@ -55,9 +58,6 @@ describe('web e2e: startup auto-selection', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// A registered workspace is the precondition for auto-selection: the first
// load has nothing to select, so the reload below is the path under test.
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
}, 180_000)
afterAll(async () => {
@@ -65,6 +65,48 @@ describe('web e2e: startup auto-selection', () => {
await scaffold?.close()
})
it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
await page.evaluate(() => {
const refs = {
root: document.querySelector('div[data-phase="hero"]'),
workspaceChip: document.querySelector('[aria-label="Choose workspace"]'),
scrollBody: document.querySelector('[data-conversation-scroll]'),
composerSeat: document.querySelector('[data-composer-seat]'),
textarea: document.querySelector('textarea'),
}
if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree')
;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs
})
// A registered Workspace is the precondition for the reload case below;
// this first connection is also the no-Workspace → Workspace path.
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
expect(await page.evaluate(() => {
const before = (window as unknown as { __heroTree: Record<string, Element> }).__heroTree
return {
phase: document.querySelector('div[data-phase]')?.getAttribute('data-phase'),
root: document.querySelector('div[data-phase="hero"]') === before.root,
workspaceChip: document.querySelector('[aria-label="Choose workspace"]') === before.workspaceChip,
scrollBody: document.querySelector('[data-conversation-scroll]') === before.scrollBody,
composerSeat: document.querySelector('[data-composer-seat]') === before.composerSeat,
textarea: document.querySelector('textarea') === before.textarea,
textareaEnabled: !(document.querySelector('textarea') as HTMLTextAreaElement).disabled,
}
})).toEqual({
phase: 'hero',
root: true,
workspaceChip: true,
scrollBody: true,
composerSeat: true,
textarea: true,
textareaEnabled: true,
})
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
// Runs before any page script on the reload below, so the first phase the

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More