fix(trajectory): bound work during long streams

This commit is contained in:
_Kerman
2026-08-04 20:37:33 +08:00
parent 5372f56280
commit 92de5bb6d4
20 changed files with 1145 additions and 114 deletions

View File

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

View File

@@ -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)
})

View File

@@ -31,6 +31,8 @@
"tests/plan-review.e2e.ts",
"tests/steering.e2e.ts",
"tests/navigation-panes.e2e.ts",
"tests/chat-scroll-fixture.ts",
"tests/trajectory-virtualization.e2e.ts",
"tests/lifecycle-chrome.e2e.ts",
"tests/details-session-lifecycle.e2e.ts",
"tests/settings-chrome.e2e.ts",