From 6b64ace474d60e9b03365786d16d1ae042ca564d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 8 Jun 2026 03:20:44 +0100 Subject: [PATCH] Contain Assistant model catalog failures --- .../v6/internal/subsystems/ai-runtime.md | 13 ++++++++ .../AI/Chat/__tests__/AIChat.test.tsx | 30 +++++++++++++++++++ .../src/components/AI/Chat/index.tsx | 15 +++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 591493114..832260001 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -2247,6 +2247,19 @@ provider route-switch metadata without changing the selected route, and labeling failed-turn or readiness recovery buttons as explicit route/model-route choices instead of implying automatic route adoption. +Assistant model-catalog failure is selector-local state, not a drawer +initialization failure. The OpenCode reference at fetched `dev` commit +`3867fa2bad0e644166e360e2e99cfe426fe71105` +`packages/opencode/src/cli/error.ts` lines 58-69 formats missing model/catalog +state as an operator-facing model-selection problem with a list-models hint, +while `packages/opencode/src/session/llm.ts` lines 96-104 resolves the +selected provider/model route independently for the stream. Pulse adapts that +by letting Assistant sessions, settings, route health, and the composer finish +opening when `/api/ai/models` fails; the catalog error stays attached to the +model selector and may be refreshed explicitly, but startup must not log or +render a broad Assistant initialization failure, and it must not replace the +selected model route. + Assistant completed-turn chrome is route-owned summary, not raw usage output. The OpenCode reference at fetched `dev` commit `3867fa2bad0e644166e360e2e99cfe426fe71105` imports `turnSummaryCommit` in diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index bbb2b42fe..61a8e8e2e 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -4,6 +4,7 @@ import { Show, createSignal } from 'solid-js'; import type { ChatMessage, ModelInfo, ModelRouteRecoveryOption } from '../types'; import type { QueuedFollowUp } from '../hooks/useChat'; import { WORKFLOW_STATUS_PACE_MS } from '../workflowStatusDisplay'; +import { logger } from '@/utils/logger'; // ── Hoisted mocks (vi.mock factories reference these) ────────────────────── @@ -37,6 +38,7 @@ const { recentModelIds?: string[]; openRequest?: number; initialSearchQuery?: string; + error?: string; onManageProviders?: () => void; onModelSelect?: (modelId: string) => void; }> = []; @@ -335,6 +337,7 @@ vi.mock('../ModelSelector', () => ({ recentModelIds?: string[]; openRequest?: number; initialSearchQuery?: string; + error?: string; onManageProviders?: () => void; onModelSelect?: (modelId: string) => void; }) => { @@ -347,6 +350,7 @@ vi.mock('../ModelSelector', () => ({ data-open-request={String(props.openRequest || 0)} data-initial-search={props.initialSearchQuery || ''} data-recent-models={(props.recentModelIds || []).join('|')} + data-error={props.error || ''} /> ); }, @@ -4321,6 +4325,32 @@ describe('AIChat', () => { }); }); + it('keeps Assistant initialized when the model catalog fails on mount', async () => { + const modelCatalogError = new Error('Model catalog unavailable'); + mockAIAPI.getModels.mockRejectedValue(modelCatalogError); + + renderChat(); + + await waitFor(() => { + expect(mockAIChatAPI.getStatus).toHaveBeenCalledTimes(1); + expect(mockAIChatAPI.listSessions).toHaveBeenCalledWith({ limit: 30 }); + expect(mockAIAPI.getSettings).toHaveBeenCalledTimes(1); + expect(mockAIAPI.getModels).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByTestId('model-selector')).toHaveAttribute( + 'data-error', + 'Model catalog unavailable', + ); + }); + expect(mockAIAPI.testProvider).toHaveBeenCalledWith('openai', 'gpt-4'); + expect(logger.error).not.toHaveBeenCalledWith( + '[AIChat] Failed to initialize:', + modelCatalogError, + ); + }); + it('does not load sessions, settings, or models when AI is not running', async () => { mockAIChatAPI.getStatus.mockResolvedValue({ running: false }); renderChat(); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 5f0f92bfe..66628e372 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -2274,7 +2274,20 @@ export const AIChat: Component = (props) => { setControlLevel('read_only'); return; } - await Promise.all([refreshSessions(), loadAIRuntimeSettings(), loadAIRuntimeModels()]); + const [sessionsResult, settingsResult, modelsResult] = await Promise.allSettled([ + refreshSessions(), + loadAIRuntimeSettings(), + loadAIRuntimeModels(), + ]); + if (sessionsResult.status === 'rejected') { + logger.error('[AIChat] Failed to load sessions:', sessionsResult.reason); + } + if (settingsResult.status === 'rejected') { + logger.error('[AIChat] Failed to load AI settings:', settingsResult.reason); + } + if (modelsResult.status === 'rejected') { + logger.debug('[AIChat] Model catalog unavailable during initialization:', modelsResult.reason); + } } catch (error) { logger.error('[AIChat] Failed to initialize:', error); }