From e983607c88f695abd1b3c04c5c9356e17baa804f Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 25 Apr 2026 22:22:33 -0400 Subject: [PATCH] fix(chat): sync currentChatRef when prefill creates a new chat session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/e2e/assistant-chat-prefill.spec.ts | 111 ++++++++++++++++++++ frontend/src/pages/AssistantChatPage.tsx | 6 ++ 2 files changed, 117 insertions(+) create mode 100644 frontend/e2e/assistant-chat-prefill.spec.ts diff --git a/frontend/e2e/assistant-chat-prefill.spec.ts b/frontend/e2e/assistant-chat-prefill.spec.ts new file mode 100644 index 00000000..4d71acd8 --- /dev/null +++ b/frontend/e2e/assistant-chat-prefill.spec.ts @@ -0,0 +1,111 @@ +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//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) + }) +}) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 4b99ec4f..2cc8aa69 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -255,6 +255,12 @@ export default function AssistantChatPage() { } setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) + // Keep the in-flight guard ref in sync. Without this, currentChatRef + // stays at its mount-time value (often a stale id from sessionStorage + // or null), so subsequent handleSend / handleTaskSubmit calls bail at + // their `currentChatRef.current !== sentForChatId` check and the AI + // response is silently dropped. + currentChatRef.current = session.session_id setMessages([{ role: 'user', content: prefill }]) setLoading(true)