Merge branch 'stack/agent-profiles-1-seam' into stack/agent-profiles-3-wire

This commit is contained in:
Yichen Jiang
2026-08-07 15:41:09 +08:00
103 changed files with 2198 additions and 170 deletions

View File

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

View File

@@ -78,6 +78,25 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
}, 60_000)
it('refuses a key no HTTP header can carry before anything is written', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
const dialog = page.getByRole('dialog', { name: '设置' })
const key = dialog.getByLabel('API 密钥')
const save = dialog.getByRole('button', { name: '保存', exact: true })
// The paste that used to save cleanly and then fail the first turn with a
// ByteString TypeError now names the field that holds it.
await key.fill('sk-\u{1F600}minimax')
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
// Clearing it restores submit: an empty field means "keep what is stored",
// never a refusal, or editing any other setting would demand the key.
await key.fill('')
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
}, 60_000)
it('saves a blank key as a reference-free provider-native profile', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
const dialog = page.getByRole('dialog', { name: '设置' })