The dashboard prefill flow in AssistantChatPage set activeChatId after creating a new session but never updated currentChatRef.current. Every later handleSend / handleTaskSubmit then tripped the `currentChatRef.current !== sentForChatId` guard that was supposed to discard responses for stale chats — and silently dropped the AI's follow-up. The user saw their submitted message but no assistant reply, no toast, no task-lane update. Mirrors what handleNewChat and handleResumeNew already do. Adds an e2e regression test that drives the dashboard prefill, submits a partial task-lane response, and asserts the second AI turn renders. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
112 lines
4.5 KiB
TypeScript
112 lines
4.5 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
|
|
/**
|
|
* Regression test for the prefill-handoff `currentChatRef` bug.
|
|
*
|
|
* Symptom: a chat session created via the dashboard prefill flow
|
|
* looked fine on the first AI turn, but submitting partial answers
|
|
* from the task lane silently dropped the AI's follow-up response.
|
|
* The user saw their answers in the chat, no assistant reply, no
|
|
* toast.
|
|
*
|
|
* Root cause: the prefill effect in `AssistantChatPage` set
|
|
* `activeChatId` without also updating `currentChatRef.current`, so
|
|
* the `currentChatRef.current !== sentForChatId` guard in
|
|
* `handleTaskSubmit` (and `handleSend`) tripped on every subsequent
|
|
* request and discarded the AI response.
|
|
*
|
|
* Strategy: drive the real prefill flow against the real backend, but
|
|
* intercept the `/chat` endpoint with `page.route` so we get
|
|
* deterministic question payloads on turn 1 and a deterministic
|
|
* follow-up on turn 2. The fix is what makes turn 2 visible.
|
|
*/
|
|
test.describe('AssistantChatPage — prefill handoff regression', () => {
|
|
test('AI follow-up renders after submitting partial task lane answers', async ({ page }) => {
|
|
let chatCallCount = 0
|
|
|
|
// Clear any persisted active-chat-id so the page does not auto-resume a
|
|
// stale session left behind by a sibling spec.
|
|
await page.addInitScript(() => {
|
|
try {
|
|
sessionStorage.removeItem('rf-active-chat-id')
|
|
sessionStorage.removeItem('rf-tasklane-meta')
|
|
} catch { /* ignore */ }
|
|
})
|
|
|
|
// Intercept only the chat endpoint. Session creation, listSessions,
|
|
// facts, suggested-fixes, etc. all hit the real backend so the page
|
|
// renders normally — only the LLM call is deterministic. The pattern
|
|
// matches `/ai-sessions/<uuid>/chat` and nothing nested beneath it.
|
|
await page.route(/\/api\/v1\/ai-sessions\/[^/]+\/chat$/, async (route) => {
|
|
if (route.request().method() !== 'POST') {
|
|
await route.fallback()
|
|
return
|
|
}
|
|
chatCallCount += 1
|
|
if (chatCallCount === 1) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
content: 'Initial diagnostic plan. Please answer the questions in the task lane.',
|
|
suggested_flows: [],
|
|
fork: null,
|
|
actions: [],
|
|
questions: [
|
|
{ text: 'Has the user recently changed their password?' },
|
|
{ text: 'Is the lockout happening at a consistent time of day?' },
|
|
],
|
|
}),
|
|
})
|
|
return
|
|
}
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
content: 'Got it — based on your answer, here is what to check next.',
|
|
suggested_flows: [],
|
|
fork: null,
|
|
actions: [],
|
|
questions: [],
|
|
}),
|
|
})
|
|
})
|
|
|
|
// Drive the prefill flow exactly the way the dashboard does. The textarea
|
|
// is keyed by its placeholder copy on QuickStartPage.
|
|
await page.goto('/')
|
|
const prefillBox = page.getByPlaceholder(/Describe the issue/i)
|
|
await expect(prefillBox).toBeVisible({ timeout: 10_000 })
|
|
await prefillBox.fill('User locked out of AD weekly')
|
|
await prefillBox.press('Enter')
|
|
|
|
// After the prefill submits we land on /pilot and the first stubbed AI
|
|
// turn surfaces the task-lane question text.
|
|
await expect(page).toHaveURL(/\/pilot/)
|
|
await expect(
|
|
page.getByText('Has the user recently changed their password?'),
|
|
).toBeVisible({ timeout: 15_000 })
|
|
|
|
// Answer the first question. UI flow: click "Answer" to open the
|
|
// textarea, type, click the inline "Answer" button to mark done.
|
|
await page.getByRole('button', { name: /^Answer$/ }).first().click()
|
|
await page.getByPlaceholder('Type your answer...').fill('No, password is months old')
|
|
await page.getByRole('button', { name: /^Answer$/ }).first().click()
|
|
|
|
// Submit the partial response. Pre-fix: the response was silently dropped
|
|
// here because `currentChatRef.current` still held the mount-time value.
|
|
await page.getByRole('button', { name: /Send 1 of 2 Responses/ }).click()
|
|
|
|
// Bug repro: the assistant message must render. Pre-fix this assertion
|
|
// fails because `handleTaskSubmit` early-returns at the
|
|
// `currentChatRef.current !== sentForChatId` guard.
|
|
await expect(
|
|
page.getByText('Got it — based on your answer, here is what to check next.'),
|
|
).toBeVisible({ timeout: 15_000 })
|
|
|
|
// Both chat calls must have actually happened.
|
|
expect(chatCallCount).toBe(2)
|
|
})
|
|
})
|