diff --git a/ui/src/pages/chat.test.tsx b/ui/src/pages/chat.test.tsx index f06d49e..78666b5 100644 --- a/ui/src/pages/chat.test.tsx +++ b/ui/src/pages/chat.test.tsx @@ -28,6 +28,7 @@ const { getLastACPOptions, setCloseLastACPConnection, resetACPMockState, + showNoticeMock, } = vi.hoisted(() => { let updateHandler: | ((update: Record, options?: { historical?: boolean }) => void) @@ -42,6 +43,7 @@ const { return { requestMock: vi.fn(), sendPromptMock: vi.fn(), + showNoticeMock: vi.fn(), emitUpdate: (update: Record, options?: { historical?: boolean }) => { updateHandler?.(update, options); }, @@ -170,7 +172,7 @@ vi.mock('@/components/notice-banner', async () => { const actual = await vi.importActual('@/components/notice-banner'); return { ...actual, - useNotice: () => ({ showNotice: vi.fn() }), + useNotice: () => ({ showNotice: showNoticeMock }), }; }); @@ -466,6 +468,7 @@ describe('ChatPage draft persistence', () => { }); requestMock.mockReset(); sendPromptMock.mockReset(); + showNoticeMock.mockReset(); refreshAuthTokenForWebSocketMock.mockClear(); resetACPMockState(); sendPromptMock.mockResolvedValue({}); @@ -788,6 +791,142 @@ describe('ChatPage draft persistence', () => { } }); + it('keeps polling until a provisioning agent becomes ready and then opens a conversation', async () => { + const createdConversation = createConversation({ + metadata: { name: 'conv-created-late' }, + spec: { sessionId: 'sess-created-late', title: 'Created after repeated polling', spritzName: 'covo' }, + status: { bindingState: 'active', lastActivityAt: '2026-03-27T10:10:00Z' }, + }); + let spritzRequestCount = 0; + requestMock.mockImplementation((path: string, options?: { method?: string }) => { + if (path === '/spritzes') { + spritzRequestCount += 1; + return Promise.resolve({ + items: [ + spritzRequestCount < 4 + ? createSpritz({ + status: { + phase: 'Provisioning', + message: 'Allocating the instance.', + acp: { state: 'starting' }, + }, + }) + : createSpritz(), + ], + }); + } + if (path === '/acp/conversations?spritz=covo') { + return Promise.resolve({ items: [] }); + } + if (path === '/acp/conversations' && options?.method === 'POST') { + return Promise.resolve(createdConversation); + } + return Promise.resolve({}); + }); + const realSetTimeout = window.setTimeout.bind(window); + const setTimeoutSpy = vi.spyOn(window, 'setTimeout').mockImplementation((( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => { + if (timeout === 2000 && typeof handler === 'function') { + queueMicrotask(() => { + handler(...args as []); + }); + return 1 as unknown as number; + } + return realSetTimeout(handler, timeout, ...(args as [])); + }) as typeof window.setTimeout); + + try { + renderChatPage('/c/covo'); + await waitFor(() => { + expect(requestMock).toHaveBeenCalledWith( + '/acp/conversations', + expect.objectContaining({ method: 'POST' }), + ); + }); + await waitFor(() => { + expect((screen.getByTestId('selected-conversation') as HTMLDivElement).textContent).toBe('conv-created-late'); + }); + expect(spritzRequestCount).toBeGreaterThanOrEqual(3); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + + it('keeps polling while a direct route spritz is still undiscoverable and starts a conversation once it appears', async () => { + const createdConversation = createConversation({ + metadata: { name: 'conv-created-after-lookup' }, + spec: { + sessionId: 'sess-created-after-lookup', + title: 'Created after lookup recovery', + spritzName: 'zeno-fresh-ridge', + }, + status: { bindingState: 'active', lastActivityAt: '2026-03-27T10:12:00Z' }, + }); + let routeLookupCount = 0; + requestMock.mockImplementation((path: string, options?: { method?: string }) => { + if (path === '/spritzes') { + return Promise.resolve({ items: [] }); + } + if (path === '/spritzes/zeno-fresh-ridge') { + routeLookupCount += 1; + if (routeLookupCount < 4) { + return Promise.reject(new Error('Not found.')); + } + return Promise.resolve( + createSpritz({ + metadata: { name: 'zeno-fresh-ridge' }, + status: { + phase: 'Ready', + acp: { state: 'ready' }, + }, + }), + ); + } + if (path === '/acp/conversations?spritz=zeno-fresh-ridge') { + return Promise.resolve({ items: [] }); + } + if (path === '/acp/conversations' && options?.method === 'POST') { + return Promise.resolve(createdConversation); + } + return Promise.resolve({}); + }); + const realSetTimeout = window.setTimeout.bind(window); + const setTimeoutSpy = vi.spyOn(window, 'setTimeout').mockImplementation((( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => { + if (timeout === 2000 && typeof handler === 'function') { + queueMicrotask(() => { + handler(...args as []); + }); + return 1 as unknown as number; + } + return realSetTimeout(handler, timeout, ...(args as [])); + }) as typeof window.setTimeout); + + try { + renderChatPage('/c/zeno-fresh-ridge'); + await waitFor(() => { + expect(requestMock).toHaveBeenCalledWith( + '/acp/conversations', + expect.objectContaining({ method: 'POST' }), + ); + }); + await waitFor(() => { + expect((screen.getByTestId('selected-conversation') as HTMLDivElement).textContent).toBe( + 'conv-created-after-lookup', + ); + }); + expect(routeLookupCount).toBeGreaterThanOrEqual(4); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + it('opens the latest conversation when an agent chat route omits the conversation id', async () => { setupRequestMock({ conversations: [ diff --git a/ui/src/pages/chat.tsx b/ui/src/pages/chat.tsx index 0ec97b5..f96af35 100644 --- a/ui/src/pages/chat.tsx +++ b/ui/src/pages/chat.tsx @@ -85,7 +85,7 @@ export function ChatPage() { const provisioningStatusLine = getProvisioningStatusLine(provisioningSpritz); // Fetch agents and conversations - const fetchAgents = useCallback(async () => { + const fetchAgents = useCallback(async (): Promise => { try { const spritzList = await request<{ items: Spritz[] }>('/spritzes'); let items = spritzList?.items || []; @@ -121,30 +121,30 @@ export function ChatPage() { setAgents(groups); if (!name) { - return; + return false; } const routeSpritz = items.find((spritz) => spritz.metadata.name === name); if (!routeSpritz) { setSelectedConversation(null); - return; + return true; } if (!isSpritzChatReady(routeSpritz)) { setSelectedConversation(null); - return; + return true; } const group = groups.find((entry) => entry.spritz.metadata.name === name); if (!group) { setSelectedConversation(null); - return; + return false; } if (urlConversationId) { const match = group.conversations.find((conversation) => conversation.metadata.name === urlConversationId); if (match) { setSelectedConversation(match); - return; + return false; } } @@ -154,11 +154,11 @@ export function ChatPage() { if (urlConversationId !== latestConversation.metadata.name) { navigate(chatConversationPath(name, latestConversation.metadata.name), { replace: true }); } - return; + return false; } if (autoCreatingConversationForRef.current === name) { - return; + return false; } autoCreatingConversationForRef.current = name; @@ -189,8 +189,10 @@ export function ChatPage() { autoCreatingConversationForRef.current = null; setCreatingConversationFor((current) => (current === name ? null : current)); } + return false; } catch (err) { showNotice(err instanceof Error ? err.message : 'Failed to load agents.'); + return false; } finally { setLoading(false); } @@ -204,17 +206,31 @@ export function ChatPage() { if (!provisioningSpritz) { return; } - const timer = window.setTimeout(() => { - void fetchAgents(); - }, PROVISIONING_POLL_INTERVAL_MS); - return () => window.clearTimeout(timer); - }, [ - fetchAgents, - provisioningSpritz?.metadata.name, - provisioningSpritz?.status?.phase, - provisioningSpritz?.status?.acp?.state, - provisioningSpritz?.status?.message, - ]); + let cancelled = false; + let timerId: number | null = null; + + const scheduleNextPoll = () => { + timerId = window.setTimeout(() => { + void pollUntilReady(); + }, PROVISIONING_POLL_INTERVAL_MS); + }; + + const pollUntilReady = async () => { + const shouldContinuePolling = await fetchAgents(); + if (cancelled || !shouldContinuePolling) { + return; + } + scheduleNextPoll(); + }; + + scheduleNextPoll(); + return () => { + cancelled = true; + if (timerId !== null) { + window.clearTimeout(timerId); + } + }; + }, [fetchAgents, provisioningSpritz?.metadata.name]); const applyConversationTitle = useCallback((conversationId: string, title?: string | null) => { const normalized = String(title || '').trim();