2 Commits

Author SHA1 Message Date
1a8cb0604f docs(ai): track currentChatRef silent-swallow follow-up in TODO
Some checks failed
Mirror to GitHub / mirror (push) Successful in 14s
CI / backend (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 2m40s
CI / e2e (pull_request) Has been skipped
The guard pattern that masked the prefill-ref bug fixed in PR #153 is
applied across handleSend, handleTaskSubmit, selectChat, refreshFacts,
refreshActiveFix, and refreshPreview. Worth either logging the
mismatch path or distinguishing expected-stale from unexpected-stale
so the next instance of this class of bug surfaces instead of hiding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 23:44:11 -04:00
e983607c88 fix(chat): sync currentChatRef when prefill creates a new chat session
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / frontend (pull_request) Failing after 2m50s
CI / e2e (pull_request) Has been skipped
CI / backend (pull_request) Failing after 10m27s
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>
2026-04-25 22:22:33 -04:00
7 changed files with 124 additions and 21 deletions

View File

@@ -9,4 +9,4 @@
## Backlog ## Backlog
- [ ] No queued backlog yet. - [ ] **AssistantChatPage `currentChatRef` guard is a silent return**`handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview` all bail with `if (currentChatRef.current !== sentForChatId) return` when stale. This is by design for chat switching, but it also silently masked the prefill-ref bug fixed in PR #153 — the user just saw "no AI response" with no log, no toast, no Sentry event. Either (a) log a `console.warn`/Sentry breadcrumb on the mismatch path so future drift is visible, or (b) split "expected stale" (chat switch) from "unexpected stale" (ref never updated) so only the latter alerts. Pair with an audit of every `currentChatRef.current = ...` assignment vs every `setActiveChatId(...)` call to make sure they're paired everywhere.

View File

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

View File

@@ -88,8 +88,6 @@ test.describe('command palette smoke tests', () => {
await flowpilotOption.click() await flowpilotOption.click()
// Phase 1 of the FlowPilot migration renamed /assistant to /pilot. await expect(page).toHaveURL(/\/assistant/)
// /assistant still 301-redirects to /pilot, so accept either landing URL.
await expect(page).toHaveURL(/\/(pilot|assistant)/)
}) })
}) })

View File

@@ -24,13 +24,9 @@ test.describe('session history smoke tests', () => {
await page.goto('/sessions') await page.goto('/sessions')
await expect( await expect(
page.getByRole('heading', { name: 'Session History', exact: true }), page.getByRole('heading', { name: 'Sessions', exact: true }),
).toBeVisible() ).toBeVisible()
// Default tab on /sessions is "AI Sessions"; flow sessions live behind
// the "Flow Sessions" tab and only that tab exposes ticket/client filters.
await page.getByRole('button', { name: 'Flow Sessions' }).click()
await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber) await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber)
await page.getByPlaceholder('Search by client name...').fill(clientName) await page.getByPlaceholder('Search by client name...').fill(clientName)

View File

@@ -14,7 +14,7 @@ test.describe('authenticated navigation smoke tests', () => {
await page.goto('/sessions') await page.goto('/sessions')
await expect( await expect(
page.getByRole('heading', { name: 'Session History', exact: true }), page.getByRole('heading', { name: 'Sessions', exact: true }),
).toBeVisible() ).toBeVisible()
}) })
@@ -30,7 +30,7 @@ test.describe('authenticated navigation smoke tests', () => {
await page.goto('/account') await page.goto('/account')
await expect( await expect(
page.getByRole('heading', { name: 'Account Management' }), page.getByRole('heading', { name: 'Account Settings' }),
).toBeVisible() ).toBeVisible()
}) })
}) })

View File

@@ -18,17 +18,9 @@ test.describe('session resume smoke tests', () => {
}) })
try { try {
// Resume flow moved off /trees onto the Flow Sessions tab of /sessions await page.goto('/trees')
// during the FlowPilot migration. The destination (/trees/:id/navigate)
// is unchanged — only the entry point shifted.
await page.goto('/sessions')
await expect(
page.getByRole('heading', { name: 'Session History', exact: true }),
).toBeVisible()
await page.getByRole('button', { name: 'Flow Sessions' }).click()
// Active sub-tab is the default and surfaces in-progress sessions.
const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).first() const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).filter({ hasText: 'Resume' }).first()
await expect(resumeCard).toBeVisible() await expect(resumeCard).toBeVisible()
await resumeCard.getByRole('button', { name: 'Resume' }).first().click() await resumeCard.getByRole('button', { name: 'Resume' }).first().click()

View File

@@ -255,6 +255,12 @@ export default function AssistantChatPage() {
} }
setChats(prev => [chatItem, ...prev]) setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id) 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 }]) setMessages([{ role: 'user', content: prefill }])
setLoading(true) setLoading(true)