diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 0dea28579..c38075ce5 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -77,10 +77,11 @@ runtime cost control, and shared AI transport surfaces. by `internal/ai/`, not Patrol runtime-finding wording, and the drawer may surface the result as actionable retry/settings status plus same-model configured-provider alternatives without converting it into - assistant-authored output. The composer must keep typed text and focus while - the selected provider is checking or failed, but it must not dispatch a user - turn until that selected provider route is ready or the operator chooses a - ready alternative. + assistant-authored output. Provider checking is a background diagnostic and + must not delay the user's first useful chat turn; a confirmed selected-route + provider error must keep typed text and focus while blocking dispatch until + the route is rechecked successfully or the operator chooses a ready + alternative. Operator interruption is likewise chat-runtime state: Stop and replacement sends must abort the active stream, clear pending tool/approval/question affordances, preserve any partial model text, return focus to the composer, 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 9edbe0404..fb532802a 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -283,12 +283,12 @@ function setViewportWidth(width: number) { window.dispatchEvent(new Event('resize')); } -async function waitForComposerSendReady() { +async function waitForProviderCheckSettled() { await waitFor(() => { expect(mockAIAPI.testProvider).toHaveBeenCalled(); }); await waitFor(() => { - expect(screen.queryByText('Checking OpenAI provider')).not.toBeInTheDocument(); + expect(screen.queryByText('Verifying OpenAI provider')).not.toBeInTheDocument(); }); await waitFor(() => { expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled(); @@ -409,7 +409,7 @@ describe('AIChat', () => { ); }); - it('keeps user input queued while the selected provider check is still running', async () => { + it('sends user input while the selected provider check is still running', async () => { mockAIAPI.getSettings.mockResolvedValue({ model: 'openai:gpt-4', chat_model: '', @@ -421,17 +421,21 @@ describe('AIChat', () => { renderChat(); - await screen.findByText('Checking OpenAI provider'); + await screen.findByText('Verifying OpenAI provider'); const textarea = screen.getByPlaceholderText( 'Ask about your infrastructure...', ) as HTMLTextAreaElement; fireEvent.input(textarea, { target: { value: 'summarize the cluster' } }); - expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled(); fireEvent.keyDown(textarea, { key: 'Enter' }); - expect(mockChat.sendMessage).not.toHaveBeenCalled(); - expect(textarea.value).toBe('summarize the cluster'); + expect(mockChat.sendMessage).toHaveBeenCalledWith( + 'summarize the cluster', + undefined, + undefined, + ); + expect(textarea.value).toBe(''); expect(document.activeElement).toBe(textarea); }); @@ -2074,7 +2078,7 @@ describe('AIChat', () => { expect(textarea.value).toBe('@redacted by policy '); }); - await waitForComposerSendReady(); + await waitForProviderCheckSettled(); fireEvent.keyDown(textarea, { key: 'Enter' }); expect(mockChat.sendMessage).toHaveBeenCalledWith( '@redacted by policy', @@ -2280,7 +2284,7 @@ describe('AIChat', () => { const textarea = screen.getByPlaceholderText('Ask about your infrastructure...'); fireEvent.input(textarea, { target: { value: 'what happened here?' } }); - await waitForComposerSendReady(); + await waitForProviderCheckSettled(); fireEvent.keyDown(textarea, { key: 'Enter' }); expect(mockChat.sendMessage).toHaveBeenCalledWith( @@ -2361,7 +2365,7 @@ describe('AIChat', () => { expect(mockAiChatStore.context.briefing?.title).toBe('Patrol finding on web-server'); fireEvent.input(textarea, { target: { value: 'what changed next?' } }); - await waitForComposerSendReady(); + await waitForProviderCheckSettled(); fireEvent.keyDown(textarea, { key: 'Enter' }); await waitFor(() => { diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 07425c4a3..75672ff38 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -554,9 +554,9 @@ export const AIChat: Component = (props) => { ); }; - const providerReadinessBlocksSend = createMemo(() => { + const providerReadinessHasBlockingError = createMemo(() => { const readiness = providerReadiness(); - if (readiness.status !== 'checking' && readiness.status !== 'error') return false; + if (readiness.status !== 'error') return false; return providerReadinessMatchesSelection(readiness); }); @@ -591,7 +591,7 @@ export const AIChat: Component = (props) => { provider, model, message: 'Provider check failed', - summary: 'Pulse could not verify the selected provider before this chat sends work.', + summary: 'Pulse could not verify the selected provider route.', recommendation: 'Check provider settings and network reachability, then retry the provider check.', action: 'open_provider_settings', @@ -1174,7 +1174,7 @@ export const AIChat: Component = (props) => { const handleSubmit = () => { const prompt = input().trim(); if (!prompt) return; - if (providerReadinessBlocksSend()) { + if (providerReadinessHasBlockingError()) { focusComposer(); return; } @@ -2041,7 +2041,7 @@ export const AIChat: Component = (props) => {