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
141 changes: 140 additions & 1 deletion ui/src/pages/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const {
getLastACPOptions,
setCloseLastACPConnection,
resetACPMockState,
showNoticeMock,
} = vi.hoisted(() => {
let updateHandler:
| ((update: Record<string, unknown>, options?: { historical?: boolean }) => void)
Expand All @@ -42,6 +43,7 @@ const {
return {
requestMock: vi.fn(),
sendPromptMock: vi.fn(),
showNoticeMock: vi.fn(),
emitUpdate: (update: Record<string, unknown>, options?: { historical?: boolean }) => {
updateHandler?.(update, options);
},
Expand Down Expand Up @@ -170,7 +172,7 @@ vi.mock('@/components/notice-banner', async () => {
const actual = await vi.importActual<typeof import('@/components/notice-banner')>('@/components/notice-banner');
return {
...actual,
useNotice: () => ({ showNotice: vi.fn() }),
useNotice: () => ({ showNotice: showNoticeMock }),
};
});

Expand Down Expand Up @@ -466,6 +468,7 @@ describe('ChatPage draft persistence', () => {
});
requestMock.mockReset();
sendPromptMock.mockReset();
showNoticeMock.mockReset();
refreshAuthTokenForWebSocketMock.mockClear();
resetACPMockState();
sendPromptMock.mockResolvedValue({});
Expand Down Expand Up @@ -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: [
Expand Down
54 changes: 35 additions & 19 deletions ui/src/pages/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function ChatPage() {
const provisioningStatusLine = getProvisioningStatusLine(provisioningSpritz);

// Fetch agents and conversations
const fetchAgents = useCallback(async () => {
const fetchAgents = useCallback(async (): Promise<boolean> => {
try {
const spritzList = await request<{ items: Spritz[] }>('/spritzes');
let items = spritzList?.items || [];
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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();
Expand Down
Loading