From 36703753e079d9f290cc3ac71829f62c467c5ca9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:08:46 -0700 Subject: [PATCH] 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). --- .../agent-init-error.test.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx new file mode 100644 index 000000000000..98f64ff93bb2 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx @@ -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 | null = null + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(new Map()) + 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() + 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) + }) +})