Files
hapi/e2e/typing-replay.spec.ts
T
AnanovoandGitHub 8dc4a50fee fix(web): prevent replaying historical assistant output (#1572)
* fix(web): prevent replaying historical assistant output

* fix(web): handle history pagination during typing handoff

* fix(web): preserve streaming handoff across history windows

* fix(web): distinguish hydrated active runs from new output

* fix(web): preserve handoff across tail hydration

* fix(web): preserve first output in user-only turns
2026-08-16 22:41:08 +08:00

236 lines
12 KiB
TypeScript

import { expect, test } from '@playwright/test'
const EXISTING_ASSISTANT_TEXT = 'This response was generated before the session was opened again.'
const EXISTING_REASONING_TEXT = 'This reasoning was generated before the session was opened again.'
const NEW_ASSISTANT_TEXT = 'This is newly generated output and it must still appear with the typewriter animation enabled.'
test('keeps the typewriter for newly generated assistant output', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?stream-new=1')
await expect(page.getByTestId('assistant-message')).toHaveCount(1)
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
await page.getByTestId('start-running').click()
await expect(page.getByTestId('assistant-message')).toHaveCount(2)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.newOutputFirstLayoutText))
.toBe('')
const newOutput = page.getByTestId('assistant-message').last().getByTestId('assistant-text')
await expect.poll(async () => await newOutput.textContent())
.toBe(NEW_ASSISTANT_TEXT)
})
test('keeps the typewriter for the first response in an empty active thread', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?empty-thread=1&active-turn=1&stream-new=1')
await expect(page.getByTestId('assistant-message')).toHaveCount(0)
await page.getByTestId('start-running').click()
await expect(page.getByTestId('assistant-message')).toHaveCount(1)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.newOutputFirstLayoutText))
.toBe('')
await expect(page.getByTestId('assistant-message').last().getByTestId('assistant-text'))
.toHaveText(NEW_ASSISTANT_TEXT)
})
test('keeps the typewriter for the first response after a running user-only turn', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&user-only=1&stream-new=1')
await page.getByTestId('start-running').click()
await expect(page.getByTestId('assistant-message')).toHaveCount(1)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.newOutputFirstLayoutText))
.toBe('')
await expect(page.getByTestId('assistant-message').last().getByTestId('assistant-text'))
.toHaveText(NEW_ASSISTANT_TEXT)
})
// Regression: a resumed session may expose an already-materialized assistant
// part as the currently running part. The first paint must show the full text,
// rather than replaying assistant-ui's typewriter animation from empty.
test('does not replay existing assistant text when opening a running session', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1')
await expect(page.getByTestId('assistant-message')).toBeVisible()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.firstLayoutText ?? ''))
.toBe(EXISTING_ASSISTANT_TEXT)
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
})
test('does not treat prepended older history as new assistant output', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&prepend-history=1')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await page.getByTestId('prepend-history').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect(page.getByTestId('assistant-message').last().getByTestId('assistant-text'))
.toHaveText(EXISTING_ASSISTANT_TEXT)
})
test('does not release the handoff when history pagination trims the old tail', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&history-window=1')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await page.getByTestId('prepend-history-window').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect(page.getByTestId('assistant-text').last()).toHaveText('Older history response 799.')
})
test('does not replay when returning to tail after bounded history pagination', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&history-window=1&return-to-tail=1')
await page.getByTestId('prepend-history-window').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await page.getByTestId('return-to-tail').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect(page.getByTestId('assistant-text').last()).toHaveText(EXISTING_ASSISTANT_TEXT)
})
test('does not replay a hydrated assistant part when a running session mounts before history', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&hydrate=1')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.firstLayoutText ?? ''))
.toBe(EXISTING_ASSISTANT_TEXT)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
})
test('does not replay active-turn output when a running session hydrates history', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&hydrate=1&active-turn=1&hydrate-active-output=1')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.firstLayoutText ?? ''))
.toBe(EXISTING_ASSISTANT_TEXT)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
})
test('does not replay history after a completed session starts running before its history arrives', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?hydrate-after-start=1')
await page.getByTestId('start-running').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.firstLayoutText ?? ''))
.toBe(EXISTING_ASSISTANT_TEXT)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
})
// Regression: sending a new message can briefly mark the previous assistant
// part as running before the new user part is committed. That status change
// must not restart the already-complete response from an empty string.
test('does not replay the previous response during a new send', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
await page.getByTestId('start-running').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningGroupStatusTypes ?? []))
.not.toContain('running')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
})
test('keeps a live response running after history pagination', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?stream-new=1&history-after-output=1')
await page.getByTestId('start-running').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('running')
await page.getByTestId('history-after-output').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('running')
})
test('does not replay existing reasoning when opening a running session', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&reasoning=1')
await expect(page.getByTestId('reasoning-text')).toBeVisible()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningFirstLayoutText ?? ''))
.toBe(EXISTING_REASONING_TEXT)
await expect(page.getByTestId('reasoning-text')).toHaveText(EXISTING_REASONING_TEXT)
})
test('does not replay hydrated reasoning when a running session mounts before history', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?running=1&reasoning=1&hydrate=1')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningFirstLayoutText ?? ''))
.toBe(EXISTING_REASONING_TEXT)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningStatusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningGroupStatusTypes ?? []))
.not.toContain('running')
await expect(page.getByTestId('reasoning-text')).toHaveText(EXISTING_REASONING_TEXT)
})
test('does not replay history reasoning after a completed session starts running before history arrives', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?reasoning=1&hydrate-after-start=1')
await page.getByTestId('start-running').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningFirstLayoutText ?? ''))
.toBe(EXISTING_REASONING_TEXT)
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningStatusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningGroupStatusTypes ?? []))
.not.toContain('running')
await expect(page.getByTestId('reasoning-text')).toHaveText(EXISTING_REASONING_TEXT)
})
test('does not replay reasoning when a new send briefly marks it running', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?reasoning=1')
await expect(page.getByTestId('reasoning-text')).toHaveText(EXISTING_REASONING_TEXT)
await page.getByTestId('start-running').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningStatusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningGroupStatusTypes ?? []))
.not.toContain('running')
await expect(page.getByTestId('reasoning-text')).toHaveText(EXISTING_REASONING_TEXT)
})
test('does not mark the previous response running before active-turn output exists', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?active-turn=1')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
await page.getByTestId('start-running').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
})
test('does not replay a response when switching into another running session', async ({ page }) => {
await page.goto('/e2e-fixtures/typing-replay-fixture.html?switch-session=1')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
await page.getByTestId('switch-session').click()
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.statusTypes?.at(-1) ?? ''))
.toBe('complete')
await expect.poll(async () => await page.evaluate(() => window.__typingReplayProbe?.reasoningGroupStatusTypes ?? []))
.not.toContain('running')
await expect(page.getByTestId('assistant-text')).toHaveText(EXISTING_ASSISTANT_TEXT)
})