test(desktop): pin visible agent-init-failure surfacing + optimistic-message survival (#63078)

Client half of leg 2. The issue's failure mode was the desktop clearing the
optimistic first message and showing nothing when the backend dropped the
turn. With the gateway now preserving the message across slow builds and
emitting a real error event on genuine failure, these tests pin the desktop
contract that makes that visible:

- an agent-init error event renders an in-transcript assistant error bubble,
  keeps the user's optimistic first message (never silently cleared), fires
  the global error toast, and releases busy/awaitingResponse so the composer
  is usable again;
- the #65567 pre-ready cancel emit renders the same way.

Covers failAssistantMessage + the gateway-event error branch, which no test
exercised at the session-state level (todo-cleanup only asserted todo
eviction).
This commit is contained in:
teknium1 2026-07-24 19:08:46 -07:00 committed by Teknium
parent 60c8fc6290
commit 36703753e0

View file

@ -0,0 +1,128 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { textPart } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $notifications, clearNotifications } from '@/store/notifications'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
const SID = 'rt-new-session'
let handleEvent: ((event: RpcEvent) => void) | null = null
let sessionStates: Map<string, ClientSessionState> | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
sessionStates = sessionStateByRuntimeIdRef.current
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
/** Seed the session as it looks right after a first-message submit: the
* optimistic user row is present, the turn is awaiting its response. */
function seedOptimisticFirstMessage() {
sessionStates!.set(SID, {
...createClientSessionState('stored-new-session', [
{ id: 'user-123-abc', role: 'user', parts: [textPart('first message of a new chat')] }
]),
busy: true,
awaitingResponse: true
})
}
describe('useMessageStream agent-init error surfacing (#63078)', () => {
beforeEach(() => {
handleEvent = null
sessionStates = null
clearNotifications()
})
afterEach(() => {
cleanup()
clearNotifications()
vi.restoreAllMocks()
})
it('renders an agent-init failure as a visible in-transcript error and keeps the optimistic first message', async () => {
await mountStream()
seedOptimisticFirstMessage()
act(() =>
handleEvent!({
payload: { message: 'agent initialization timed out after 601s — your message was not sent; retry once the session is ready' },
session_id: SID,
type: 'error'
})
)
const state = sessionStates!.get(SID)!
// The user's optimistic first message must survive — the failure mode of
// #63078 was the message silently vanishing into a blank session.
const userRows = state.messages.filter(m => m.role === 'user')
expect(userRows).toHaveLength(1)
expect(userRows[0]!.id).toBe('user-123-abc')
// The failure is VISIBLE in the session view: an assistant error bubble...
const errorRows = state.messages.filter(m => m.role === 'assistant' && m.error)
expect(errorRows).toHaveLength(1)
expect(errorRows[0]!.error).toContain('your message was not sent')
// ...and the composer is released (no forever-spinner on a dead turn).
expect(state.busy).toBe(false)
expect(state.awaitingResponse).toBe(false)
// A global toast also fired (turn-ending errors are easy to miss inline).
expect($notifications.get().some(n => n.kind === 'error' && n.message?.includes('was not sent'))).toBe(true)
})
it('renders the pre-ready cancel error event (#65567 server emit) visibly', async () => {
await mountStream()
seedOptimisticFirstMessage()
act(() =>
handleEvent!({
payload: { message: 'Turn cancelled before the agent was ready' },
session_id: SID,
type: 'error'
})
)
const state = sessionStates!.get(SID)!
expect(state.messages.some(m => m.role === 'assistant' && m.error?.includes('cancelled'))).toBe(true)
expect(state.messages.some(m => m.id === 'user-123-abc')).toBe(true)
expect(state.busy).toBe(false)
})
})