Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/onboarding-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
# pip install -e "packages/prompt-composer[dev]"
# pip install -e "services/convsim-core[dev]"
# python -m pytest e2e/onboarding/test_p1_happy_path.py \
# e2e/onboarding/test_p2_instant_play.py \
# e2e/onboarding/test_p7_regression_loop.py -v

name: Onboarding e2e (fast trio)
Expand Down Expand Up @@ -64,11 +63,11 @@ jobs:
- name: Install convsim-core
run: pip install -e "services/convsim-core[dev]"

- name: Run onboarding fast trio (P1 / P2 / P7)
# P2 (instant play) was removed with the no-model demo path (issue #473).
- name: Run onboarding fast pair (P1 / P7)
run: |
echo "Local: python -m pytest e2e/onboarding/test_p1_happy_path.py e2e/onboarding/test_p2_instant_play.py e2e/onboarding/test_p7_regression_loop.py -v"
echo "Local: python -m pytest e2e/onboarding/test_p1_happy_path.py e2e/onboarding/test_p7_regression_loop.py -v"
python -m pytest \
e2e/onboarding/test_p1_happy_path.py \
e2e/onboarding/test_p2_instant_play.py \
e2e/onboarding/test_p7_regression_loop.py \
-v --tb=short
99 changes: 8 additions & 91 deletions apps/web/src/__tests__/Conversation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1408,109 +1408,26 @@ describe('Conversation screen', () => {
})
})

describe('runtime hint labeling + model-ready toast (issue #383)', () => {
describe('no runtime badges or model-ready toast (issue #473)', () => {
afterEach(() => {
localStorage.clear()
})

it('labels a scripted session with the runtime badge', async () => {
localStorage.setItem('convsim.active_runtime_hint', 'scripted')
mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse })
renderConversation()
await waitFor(() =>
expect(screen.getByTestId('runtime-label')).toHaveTextContent(/scripted practice run/i),
)
})

it('completing a background install shows the toast and clears the scripted hint so the next real-AI session is not mislabeled', async () => {
it('renders no runtime badge and no toast even with stale legacy keys present', async () => {
// Old app versions wrote these; they must be inert now.
localStorage.setItem('convsim.active_runtime_hint', 'scripted')
localStorage.setItem('convsim.tutorial.install_id', '42')
mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse })
mockApi.getSetupInstallStatus.mockResolvedValue({
ok: true,
data: { id: 42, status: 'complete', registry_id: 'qwen3-4b-q4', stages: [], error_message: null, created_at: '', updated_at: '' },
} as never)

renderConversation()

await waitFor(() => expect(screen.getByTestId('model-ready-toast')).toBeInTheDocument())

// The current scripted session keeps its badge (captured at mount)…
expect(screen.getByTestId('runtime-label')).toHaveTextContent(/scripted practice run/i)
// …but the hint is cleared so a subsequent genuine-AI conversation isn't
// labeled "Scripted practice run" (the real model is now the active runtime).
expect(localStorage.getItem('convsim.active_runtime_hint')).toBeNull()
expect(localStorage.getItem('convsim.tutorial.install_id')).toBeNull()
})

it('labels a fake session with the "Demo mode" badge', async () => {
localStorage.setItem('convsim.active_runtime_hint', 'fake')
mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse })
renderConversation()
await waitFor(() =>
expect(screen.getByTestId('runtime-label')).toHaveTextContent(/demo mode/i),
expect(screen.getByRole('heading', { name: /^conversation$/i })).toBeInTheDocument(),
)
})

it('toast "Switch now" clears the runtime hint and navigates to the library', async () => {
localStorage.setItem('convsim.active_runtime_hint', 'scripted')
localStorage.setItem('convsim.tutorial.install_id', '42')
mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse })
mockApi.getSetupInstallStatus.mockResolvedValue({
ok: true,
data: { id: 42, status: 'complete', registry_id: 'qwen3-4b-q4', stages: [], error_message: null, created_at: '', updated_at: '' },
} as never)

renderConversation()

await waitFor(() => expect(screen.getByTestId('model-ready-toast')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /switch now/i }))

await waitFor(() => expect(screen.getByText('Library page')).toBeInTheDocument())
expect(localStorage.getItem('convsim.active_runtime_hint')).toBeNull()
expect(localStorage.getItem('convsim.tutorial.install_id')).toBeNull()
})

it('toast "After this conversation" dismisses the toast without leaving the session', async () => {
localStorage.setItem('convsim.active_runtime_hint', 'scripted')
localStorage.setItem('convsim.tutorial.install_id', '42')
mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse })
mockApi.getSetupInstallStatus.mockResolvedValue({
ok: true,
data: { id: 42, status: 'complete', registry_id: 'qwen3-4b-q4', stages: [], error_message: null, created_at: '', updated_at: '' },
} as never)

renderConversation()

await waitFor(() => expect(screen.getByTestId('model-ready-toast')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /after this conversation/i }))

await waitFor(() =>
expect(screen.queryByTestId('model-ready-toast')).not.toBeInTheDocument(),
)
// Still in the conversation — the runtime is never swapped mid-scene.
expect(screen.queryByText('Library page')).not.toBeInTheDocument()
})

it('a failed background install clears the install-id key and shows no toast', async () => {
localStorage.setItem('convsim.active_runtime_hint', 'scripted')
localStorage.setItem('convsim.tutorial.install_id', '42')
mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse })
mockApi.getSetupInstallStatus.mockResolvedValue({
ok: true,
data: { id: 42, status: 'failed', registry_id: 'qwen3-4b-q4', stages: [], error_message: 'disk full', created_at: '', updated_at: '' },
} as never)

renderConversation()

// The install-id key is cleared so we stop polling a dead job…
await waitFor(() =>
expect(localStorage.getItem('convsim.tutorial.install_id')).toBeNull(),
)
// …no error modal interrupts the scene, and the scripted badge stays put
// because the real model never became active.
expect(screen.queryByTestId('runtime-label')).not.toBeInTheDocument()
expect(screen.queryByTestId('model-ready-toast')).not.toBeInTheDocument()
expect(localStorage.getItem('convsim.active_runtime_hint')).toBe('scripted')
// No background-install polling is wired to the legacy key any more.
expect(mockApi.getSetupInstallStatus).not.toHaveBeenCalled()
})
})
})
})
61 changes: 10 additions & 51 deletions apps/web/src/__tests__/Debrief.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,43 +443,23 @@ describe('Debrief screen', () => {
})
})

describe('model-ready upgrade CTA (issue #383)', () => {
it('does not render "Try it with the real AI" without the modelReadyAfterTutorial route state', async () => {
describe('no tutorial upgrade CTA (issue #473)', () => {
it('never renders "Try it with the real AI" — every conversation is already the real AI', async () => {
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief()
renderDebrief({ modelReadyAfterTutorial: true, isScripted: true })
await waitFor(() =>
expect(screen.getByTestId('replay-btn')).toBeInTheDocument(),
)
// Legacy route state (written by old app versions) is inert.
expect(screen.queryByTestId('try-real-ai-btn')).not.toBeInTheDocument()
})

it('renders "Try it with the real AI" when the model became ready during the tutorial', async () => {
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ modelReadyAfterTutorial: true })
await waitFor(() =>
expect(screen.getByTestId('try-real-ai-btn')).toBeInTheDocument(),
)
expect(screen.getByTestId('try-real-ai-btn')).toHaveTextContent(/try it with the real ai/i)
})

it('navigates to the library when "Try it with the real AI" is clicked', async () => {
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ modelReadyAfterTutorial: true })
await waitFor(() =>
expect(screen.getByTestId('try-real-ai-btn')).toBeInTheDocument(),
)
fireEvent.click(screen.getByTestId('try-real-ai-btn'))
await waitFor(() =>
expect(screen.getByText('Library page')).toBeInTheDocument(),
)
})
})

describe('voice invite card (issue #385)', () => {
it('shows the voice invite card after a real AI conversation when invite is pending', async () => {
mockReadVoiceInviteState.mockReturnValue('pending')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ isScripted: false })
renderDebrief()
await waitFor(() =>
expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(),
)
Expand All @@ -492,7 +472,7 @@ describe('Debrief screen', () => {
// conversation does not re-show it.
mockReadVoiceInviteState.mockReturnValue('pending')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ isScripted: false })
renderDebrief()
await waitFor(() =>
expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(),
)
Expand All @@ -501,20 +481,10 @@ describe('Debrief screen', () => {
expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument()
})

it('does not show voice invite card when the session was scripted', async () => {
mockReadVoiceInviteState.mockReturnValue('pending')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ isScripted: true })
await waitFor(() =>
expect(screen.getByTestId('summary-section')).toBeInTheDocument(),
)
expect(screen.queryByTestId('voice-invite-card')).not.toBeInTheDocument()
})

it('does not show voice invite card when already dismissed', async () => {
mockReadVoiceInviteState.mockReturnValue('dismissed')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ isScripted: false })
renderDebrief()
await waitFor(() =>
expect(screen.getByTestId('summary-section')).toBeInTheDocument(),
)
Expand All @@ -524,7 +494,7 @@ describe('Debrief screen', () => {
it('does not show voice invite card when already in setup state', async () => {
mockReadVoiceInviteState.mockReturnValue('setup')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ isScripted: false })
renderDebrief()
await waitFor(() =>
expect(screen.getByTestId('summary-section')).toBeInTheDocument(),
)
Expand All @@ -534,7 +504,7 @@ describe('Debrief screen', () => {
it('"Maybe later" hides the card and persists dismissed state', async () => {
mockReadVoiceInviteState.mockReturnValue('pending')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ isScripted: false })
renderDebrief()
await waitFor(() =>
expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(),
)
Expand All @@ -546,7 +516,7 @@ describe('Debrief screen', () => {
it('"Set up voice" hides the card, persists setup state, and navigates to settings', async () => {
mockReadVoiceInviteState.mockReturnValue('pending')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief({ isScripted: false })
renderDebrief()
await waitFor(() =>
expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument(),
)
Expand All @@ -557,17 +527,6 @@ describe('Debrief screen', () => {
)
})

it('does not show voice invite card when isScripted is absent from route state', async () => {
// No route state at all (e.g. navigated directly) — default is to treat as non-scripted
mockReadVoiceInviteState.mockReturnValue('pending')
mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse })
renderDebrief()
await waitFor(() =>
expect(screen.getByTestId('summary-section')).toBeInTheDocument(),
)
// No route state = isScripted defaults false, so invite appears when pending
expect(screen.getByTestId('voice-invite-card')).toBeInTheDocument()
})
})

describe('replay same setup button', () => {
Expand Down
Loading