Merge master into fix/conversation-column-one-axis-scroll
This commit is contained in:
@@ -121,6 +121,21 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
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/
|
||||
|
||||
@@ -260,7 +260,9 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
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 => carries(event, CONTINUE_PROMPT))).toHaveLength(1)
|
||||
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'
|
||||
))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 let tests identify semantic
|
||||
// rows without depending on CSS-module names or the eventual virtualizer DOM.
|
||||
// persisted conversations, while unique markers identify semantic rows
|
||||
// without depending on CSS-module names or virtualizer DOM positions.
|
||||
import {
|
||||
CallId,
|
||||
createAssistantMessage,
|
||||
@@ -184,7 +184,6 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: text(
|
||||
|
||||
@@ -322,7 +322,6 @@ function smallSidebarFixture(): string {
|
||||
const session = Session.create(SessionId('perf-small-template'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: text('Inspect this compact synthetic session.'),
|
||||
@@ -346,7 +345,6 @@ function longHistoryFixture(): string {
|
||||
for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: text(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -83,10 +83,7 @@ async function stopServer(server: Server): Promise<void> {
|
||||
/** 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'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
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' },
|
||||
|
||||
130
apps/web/tests/math-rendering.e2e.ts
Normal file
130
apps/web/tests/math-rendering.e2e.ts
Normal 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)
|
||||
})
|
||||
103
apps/web/tests/pwsh-terminal.e2e.ts
Normal file
103
apps/web/tests/pwsh-terminal.e2e.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
20
apps/web/tests/pwsh-terminal.overlay.yml
Normal file
20
apps/web/tests/pwsh-terminal.overlay.yml
Normal 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'
|
||||
@@ -32,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[] {
|
||||
@@ -63,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))
|
||||
@@ -86,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)
|
||||
@@ -157,19 +158,24 @@ 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)
|
||||
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(WAKE)
|
||||
await input.press('Enter')
|
||||
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(() => 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)
|
||||
|
||||
|
||||
@@ -379,26 +379,21 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
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)
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -481,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
|
||||
@@ -523,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
|
||||
@@ -534,14 +549,17 @@ 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}}')
|
||||
// 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,
|
||||
/~\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}}')
|
||||
|
||||
@@ -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,8 +271,11 @@ 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 () => {
|
||||
@@ -253,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()
|
||||
@@ -264,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')
|
||||
@@ -356,21 +399,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
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)
|
||||
|
||||
@@ -196,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":""}}]}',
|
||||
@@ -244,14 +247,16 @@ 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')
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- 'button "Failed Bash Error: tool call aborted" [expanded]':
|
||||
- img
|
||||
- text: "Failed Bash Error: tool call aborted"
|
||||
@@ -30,4 +30,4 @@
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -36,7 +36,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -44,5 +44,6 @@
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -51,7 +51,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -59,5 +59,6 @@
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -31,7 +31,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -39,5 +39,6 @@
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -23,7 +23,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -31,5 +31,6 @@
|
||||
- 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
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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"
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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.":
|
||||
@@ -25,7 +25,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -33,5 +33,6 @@
|
||||
- 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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
47
apps/web/tests/snapshots/math-rendering/ui.expected.md
Normal file
47
apps/web/tests/snapshots/math-rendering/ui.expected.md
Normal 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
|
||||
@@ -20,7 +20,7 @@
|
||||
- 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}}
|
||||
- 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
|
||||
@@ -46,7 +46,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}}Ran for {{duration}}
|
||||
- text: 7/25 {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -55,4 +55,4 @@
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- img
|
||||
- searchbox "Search trajectory"
|
||||
- region "Trajectory timeline":
|
||||
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
|
||||
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "SYSTEM, Initial System Prompt":
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
- 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" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -36,7 +36,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -44,5 +44,6 @@
|
||||
- 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
|
||||
|
||||
19
apps/web/tests/snapshots/pwsh-terminal/seed.jsonl
Normal file
19
apps/web/tests/snapshots/pwsh-terminal/seed.jsonl
Normal 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"}}}
|
||||
@@ -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."
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -31,7 +31,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -39,5 +39,6 @@
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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"
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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]
|
||||
|
||||
@@ -8,18 +8,14 @@
|
||||
- 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":
|
||||
- button "Context injection goal":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- text: Context injection goal
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- region "To-dos":
|
||||
|
||||
@@ -10,32 +10,35 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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}}Ran for {{duration}} Edited queue item {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- 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":
|
||||
- button "Steer queued message" [disabled]:
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
@@ -44,5 +47,5 @@
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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:
|
||||
|
||||
@@ -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.)
|
||||
@@ -33,14 +33,14 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}}Ran for {{duration}}
|
||||
- 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 read-only
|
||||
- textbox "Message the agent"
|
||||
@@ -51,4 +51,4 @@
|
||||
- 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
|
||||
|
||||
@@ -33,14 +33,14 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}}Ran for {{duration}}
|
||||
- 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
|
||||
@@ -49,4 +49,4 @@
|
||||
- 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
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -24,7 +24,7 @@
|
||||
- img
|
||||
- text: Ask question waiting
|
||||
- status: Deep diving...
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- text: "Interjection Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy":
|
||||
- img
|
||||
- region "Ready to continue?":
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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,7 +22,7 @@
|
||||
- img
|
||||
- img
|
||||
- text: Ask question 1/1 answered
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
@@ -37,7 +37,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -45,5 +45,6 @@
|
||||
- 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
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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 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
|
||||
@@ -28,7 +28,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}} Now give the same explanation to a human reader. {{clock}}
|
||||
- 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]:
|
||||
@@ -43,10 +43,11 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- 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 Context 6% of 128K Cache hit 99% Input 15.6K tok · Output 158 tok
|
||||
- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- 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
|
||||
@@ -23,7 +23,7 @@
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
@@ -31,5 +31,6 @@
|
||||
- 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
|
||||
|
||||
@@ -21,7 +21,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// Two goldens pin the transient Host projection and its durable handoff: the
|
||||
// mid-turn state renders accepted steering from session/queue while the
|
||||
// question blocks admission, then the settled state renders the same message
|
||||
// from steering/message beside the reply that obeys it.
|
||||
// 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()
|
||||
@@ -45,6 +45,12 @@ 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
|
||||
@@ -74,8 +80,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
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 })
|
||||
@@ -112,7 +119,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
}
|
||||
|
||||
// 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')
|
||||
@@ -124,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)
|
||||
@@ -182,7 +188,7 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
|
||||
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])
|
||||
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)
|
||||
@@ -203,9 +209,8 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await settled
|
||||
|
||||
const steerEvents = sessionEvents.filter(event => event.type === 'steering/message')
|
||||
const steerEvents = claimedMessages(sessionEvents, STEER)
|
||||
expect(steerEvents).toHaveLength(1)
|
||||
expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
|
||||
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(0)
|
||||
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 })
|
||||
@@ -259,7 +264,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
|
||||
const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText })
|
||||
await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0)
|
||||
expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
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.
|
||||
|
||||
309
apps/web/tests/trajectory-virtualization.e2e.ts
Normal file
309
apps/web/tests/trajectory-virtualization.e2e.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Browser contract for the tail-paged, virtualized Trajectory ledger. The
|
||||
// scenario proves that semantic row identity survives an older-page prepend,
|
||||
// DOM mounting stays bounded, and every scroll range remains reachable.
|
||||
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 } from '@deepseek-ai/dsh-llm-replay'
|
||||
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 = 'trajectory-virtualization-e2e'
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TRAJECTORY_VIRTUAL',
|
||||
title: 'TRAJECTORY_VIRTUAL long ledger',
|
||||
turns: 88,
|
||||
})
|
||||
const MAX_MOUNTED_ROWS = 160
|
||||
const GEOMETRY_TOLERANCE = 2
|
||||
const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED'
|
||||
const STREAM_TEXT = Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
).join('') + STREAM_MARKER
|
||||
|
||||
const STREAM_CHUNKS: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from({ length: 80 }, (_, index): StreamChunk => ({
|
||||
type: 'text-delta',
|
||||
index: 0,
|
||||
text: `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
})),
|
||||
{ type: 'text-delta', index: 0, text: STREAM_MARKER },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: STREAM_TEXT } },
|
||||
{ type: 'usage', usage: { inputTokens: 2_700, outputTokens: 240 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly clientHeight: number
|
||||
readonly scrollHeight: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
interface RowAnchor {
|
||||
readonly key: string
|
||||
readonly top: number
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
|
||||
await result.click()
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
|
||||
.last()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function openTrajectory(page: Page): Promise<void> {
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
const pane = page.locator('[data-trajectory-scroll]')
|
||||
await pane.waitFor({ timeout: 30_000 })
|
||||
await page.locator('[data-trajectory-scroll] table[data-scroll-ready="true"]')
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function logicalRows(page: Page): Promise<number> {
|
||||
const raw = await page.locator('[data-trajectory-scroll] table').getAttribute('aria-rowcount')
|
||||
if (raw === null || !/^\d+$/.test(raw)) {
|
||||
throw new Error(`trajectory table has invalid aria-rowcount ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return Number(raw)
|
||||
}
|
||||
|
||||
async function mountedRows(page: Page): Promise<number> {
|
||||
return page.locator('[data-trajectory-scroll] tr[data-trajectory-row-key]').count()
|
||||
}
|
||||
|
||||
async function geometry(page: Page): Promise<ScrollGeometry> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate(host => ({
|
||||
clientHeight: host.clientHeight,
|
||||
scrollHeight: host.scrollHeight,
|
||||
scrollTop: host.scrollTop,
|
||||
}))
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))
|
||||
}))
|
||||
}
|
||||
|
||||
async function scrollToRatio(page: Page, ratio: number): Promise<void> {
|
||||
await page.locator('[data-trajectory-scroll]').evaluate((host, value) => {
|
||||
const maximum = Math.max(0, host.scrollHeight - host.clientHeight)
|
||||
host.scrollTop = Math.round(maximum * value)
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
}, ratio)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function firstVisibleRow(page: Page): Promise<RowAnchor> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host) => {
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find((candidate) => {
|
||||
const box = candidate.getBoundingClientRect()
|
||||
return candidate.dataset.requestOnly !== 'true'
|
||||
&& box.bottom > hostBox.top
|
||||
&& box.top < hostBox.bottom
|
||||
})
|
||||
const key = row?.dataset.trajectoryRowKey
|
||||
if (row === undefined || key === undefined) {
|
||||
throw new Error('trajectory scrollport has no visible semantic row')
|
||||
}
|
||||
return { key, top: row.getBoundingClientRect().top - hostBox.top }
|
||||
})
|
||||
}
|
||||
|
||||
async function rowTop(page: Page, key: string): Promise<number | null> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host, targetKey) => {
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find(candidate => candidate.dataset.trajectoryRowKey === targetKey)
|
||||
return row === undefined
|
||||
? null
|
||||
: row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
}, key)
|
||||
}
|
||||
|
||||
async function loadToFirstTurn(page: Page): Promise<void> {
|
||||
const marker = FIXTURE.markers.user(1)
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await scrollToRatio(page, 0)
|
||||
if (await page.getByText(marker, { exact: false }).count() > 0) return
|
||||
const before = await logicalRows(page)
|
||||
await expect.poll(async () => ({
|
||||
marker: await page.getByText(marker, { exact: false }).count() > 0,
|
||||
rows: await logicalRows(page),
|
||||
}), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before })
|
||||
}
|
||||
throw new Error('trajectory did not reach the first turn after twelve older-page requests')
|
||||
}
|
||||
|
||||
describe('web e2e: Trajectory virtualization over tail-paged history', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let replayDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-trajectory-virtualization-'))
|
||||
const replayFixture = join(replayDir, 'session.jsonl')
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayFixture, FIXTURE.log)
|
||||
await writeFile(replayOverride, JSON.stringify([{
|
||||
kind: 'chunks',
|
||||
chunks: STREAM_CHUNKS,
|
||||
} satisfies ReplayEntry]))
|
||||
scaffold = await launchWebScaffold({
|
||||
paceMs: 10,
|
||||
replayFixture,
|
||||
replayOverride,
|
||||
})
|
||||
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 page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('retains identity on prepend and reaches the bounded virtual range', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-virtualization'))
|
||||
await openSeed(page)
|
||||
|
||||
let held = false
|
||||
let releaseHistory: () => void = () => {}
|
||||
let finishHeldRequest: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => { releaseHistory = resolve })
|
||||
const heldRequestFinished = new Promise<void>((resolve) => { finishHeldRequest = resolve })
|
||||
await 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
|
||||
try {
|
||||
await route.continue()
|
||||
} finally {
|
||||
finishHeldRequest()
|
||||
}
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
try {
|
||||
await openTrajectory(page)
|
||||
const initialRows = await logicalRows(page)
|
||||
expect(initialRows).toBeGreaterThan(0)
|
||||
expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await scrollToRatio(page, 0)
|
||||
await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
|
||||
const anchor = await firstVisibleRow(page)
|
||||
const selectedRow = page.locator(
|
||||
`[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`,
|
||||
)
|
||||
await selectedRow.click()
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
|
||||
releaseHistory()
|
||||
await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows)
|
||||
await nextPaint(page)
|
||||
await expect.poll(async () => {
|
||||
const top = await rowTop(page, anchor.key)
|
||||
return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top)
|
||||
}, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await loadToFirstTurn(page)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.user(1), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
const fullRows = await logicalRows(page)
|
||||
|
||||
await scrollToRatio(page, 0.5)
|
||||
const middle = await geometry(page)
|
||||
const maximum = middle.scrollHeight - middle.clientHeight
|
||||
expect(middle.scrollTop).toBeGreaterThan(maximum * 0.25)
|
||||
expect(middle.scrollTop).toBeLessThan(maximum * 0.75)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect(await mountedRows(page)).toBeLessThan(fullRows)
|
||||
|
||||
await scrollToRatio(page, 1)
|
||||
await expect.poll(async () => {
|
||||
const value = await geometry(page)
|
||||
return value.scrollHeight - value.clientHeight - value.scrollTop
|
||||
}, { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
const trajectoryScroll = page.locator('[data-trajectory-scroll]')
|
||||
await trajectoryScroll.evaluate((host) => {
|
||||
const measuredWindow = window as Window & { __trajectoryScrollCalls?: number }
|
||||
measuredWindow.__trajectoryScrollCalls = 0
|
||||
const original = host.scrollTo.bind(host)
|
||||
const trackedScrollTo = (...args: [ScrollToOptions?] | [number, number]) => {
|
||||
measuredWindow.__trajectoryScrollCalls = (measuredWindow.__trajectoryScrollCalls ?? 0) + 1
|
||||
Reflect.apply(original, host, args)
|
||||
}
|
||||
host.scrollTo = trackedScrollTo as typeof host.scrollTo
|
||||
})
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('Stream one deterministic response while Trajectory remains visible.')
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await page.getByText('stream fragment 01', { exact: false }).waitFor({ timeout: 30_000 })
|
||||
await nextPaint(page)
|
||||
const streamingScrollCalls = await trajectoryScroll.evaluate(() => {
|
||||
return (window as Window & { __trajectoryScrollCalls?: number })
|
||||
.__trajectoryScrollCalls ?? 0
|
||||
})
|
||||
expect(streamingScrollCalls).toBeLessThanOrEqual(5)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({ pageErrors: [], warnings: [] })
|
||||
} finally {
|
||||
releaseHistory()
|
||||
if (held) await heldRequestFinished
|
||||
await page.unroute('**/api/session.history')
|
||||
}
|
||||
}, 180_000)
|
||||
})
|
||||
Reference in New Issue
Block a user