diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 958f3ba19e..1fa72feda7 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -732,7 +732,7 @@ func TestSubscribeDeliversRetryEventViaPubsubOnce(t *testing.T) { expected := &codersdk.ChatStreamRetry{ Attempt: 1, DelayMs: (1500 * time.Millisecond).Milliseconds(), - Error: "OpenAI is rate limiting requests (HTTP 429). Please try again later.", + Error: "OpenAI is rate limiting requests (HTTP 429).", Kind: chaterror.KindRateLimit, Provider: "openai", StatusCode: 429, @@ -773,7 +773,7 @@ func TestSubscribePrefersStructuredErrorPayloadViaPubsub(t *testing.T) { defer cancel() classified := chaterror.ClassifiedError{ - Message: "OpenAI is rate limiting requests (HTTP 429). Please try again later.", + Message: "OpenAI is rate limiting requests (HTTP 429).", Kind: chaterror.KindRateLimit, Provider: "openai", Retryable: true, diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 522a6c5f31..3cbdb1eeb8 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -178,7 +178,7 @@ func normalizeClassification(classified ClassifiedError) ClassifiedError { classified.Kind = KindGeneric } if classified.Message == "" { - classified.Message = userFacingMessage(classified) + classified.Message = terminalMessage(classified) } return classified } diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 16aacd7b87..c7d0a9e204 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -22,7 +22,7 @@ func TestClassify(t *testing.T) { name: "AmbiguousOverloadKeepsProviderUnknown", err: xerrors.New("status 529 from upstream"), want: chaterror.ClassifiedError{ - Message: "The AI provider is temporarily overloaded (HTTP 529). Please try again later.", + Message: "The AI provider is temporarily overloaded (HTTP 529).", Kind: chaterror.KindOverloaded, Provider: "", Retryable: true, @@ -33,7 +33,7 @@ func TestClassify(t *testing.T) { name: "ExplicitAnthropicOverload", err: xerrors.New("anthropic overloaded_error"), want: chaterror.ClassifiedError{ - Message: "Anthropic is temporarily overloaded. Please try again later.", + Message: "Anthropic is temporarily overloaded.", Kind: chaterror.KindOverloaded, Provider: "anthropic", Retryable: true, @@ -110,7 +110,7 @@ func TestClassify(t *testing.T) { name: "ExplicitStatus429ClassifiesAsRateLimit", err: xerrors.New("status 429 from upstream"), want: chaterror.ClassifiedError{ - Message: "The AI provider is rate limiting requests (HTTP 429). Please try again later.", + Message: "The AI provider is rate limiting requests (HTTP 429).", Kind: chaterror.KindRateLimit, Provider: "", Retryable: true, @@ -132,7 +132,7 @@ func TestClassify(t *testing.T) { name: "ServiceUnavailableClassifiesAsRetryableTimeout", err: xerrors.New("service unavailable"), want: chaterror.ClassifiedError{ - Message: "The AI provider is temporarily unavailable. Please try again later.", + Message: "The AI provider is temporarily unavailable.", Kind: chaterror.KindTimeout, Provider: "", Retryable: true, @@ -176,7 +176,7 @@ func TestClassify(t *testing.T) { name: "DeadlineExceededStaysNonRetryableTimeout", err: context.DeadlineExceeded, want: chaterror.ClassifiedError{ - Message: "The request timed out before it completed. Please try again.", + Message: "The request timed out before it completed.", Kind: chaterror.KindTimeout, Provider: "", Retryable: false, @@ -279,7 +279,7 @@ func TestClassify_TransportFailuresUseBroaderRetryMessage(t *testing.T) { require.True(t, classified.Retryable) require.Equal( t, - "The AI provider is temporarily unavailable. Please try again later.", + "The AI provider is temporarily unavailable.", classified.Message, ) }) @@ -299,7 +299,7 @@ func TestClassify_StartupTimeoutWrappedClassificationWins(t *testing.T) { ) require.Equal(t, chaterror.ClassifiedError{ - Message: "OpenAI did not start responding in time. Please try again.", + Message: "OpenAI did not start responding in time.", Kind: chaterror.KindStartupTimeout, Provider: "openai", Retryable: true, @@ -315,7 +315,7 @@ func TestWithProviderUsesExplicitHint(t *testing.T) { enriched := classified.WithProvider("azure openai") require.Equal(t, chaterror.ClassifiedError{ - Message: "Azure OpenAI is rate limiting requests (HTTP 429). Please try again later.", + Message: "Azure OpenAI is rate limiting requests (HTTP 429).", Kind: chaterror.KindRateLimit, Provider: "azure", Retryable: true, @@ -331,7 +331,7 @@ func TestWithProviderAddsProviderWhenUnknown(t *testing.T) { enriched := classified.WithProvider("openai") require.Equal(t, chaterror.ClassifiedError{ - Message: "OpenAI is rate limiting requests (HTTP 429). Please try again later.", + Message: "OpenAI is rate limiting requests (HTTP 429).", Kind: chaterror.KindRateLimit, Provider: "openai", Retryable: true, diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index f038643901..850c7ab461 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -5,78 +5,113 @@ import ( "strings" ) -func userFacingMessage(classified ClassifiedError) string { +// terminalMessage produces the user-facing error description shown +// when retries are exhausted. It includes HTTP status codes and +// actionable remediation guidance. +func terminalMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case KindOverloaded: - return optionalStatusMessage( - subject, - classified.StatusCode, - "%s is temporarily overloaded (HTTP %d). Please try again later.", - "%s is temporarily overloaded. Please try again later.", - ) + if classified.StatusCode > 0 { + return fmt.Sprintf( + "%s is temporarily overloaded (HTTP %d).", + subject, classified.StatusCode, + ) + } + return fmt.Sprintf("%s is temporarily overloaded.", subject) + case KindRateLimit: - return optionalStatusMessage( - subject, - classified.StatusCode, - "%s is rate limiting requests (HTTP %d). Please try again later.", - "%s is rate limiting requests. Please try again later.", - ) + if classified.StatusCode > 0 { + return fmt.Sprintf( + "%s is rate limiting requests (HTTP %d).", + subject, classified.StatusCode, + ) + } + return fmt.Sprintf("%s is rate limiting requests.", subject) + case KindTimeout: if classified.StatusCode > 0 { return fmt.Sprintf( - "%s is temporarily unavailable (HTTP %d). Please try again later.", - subject, - classified.StatusCode, + "%s is temporarily unavailable (HTTP %d).", + subject, classified.StatusCode, ) } - if classified.Retryable { - return fmt.Sprintf("%s is temporarily unavailable. Please try again later.", subject) + if !classified.Retryable { + return "The request timed out before it completed." } - return "The request timed out before it completed. Please try again." + return fmt.Sprintf("%s is temporarily unavailable.", subject) + case KindStartupTimeout: - return fmt.Sprintf("%s did not start responding in time. Please try again.", subject) + return fmt.Sprintf( + "%s did not start responding in time.", subject, + ) + case KindAuth: - if displayName := providerDisplayName(classified.Provider); displayName != "" { - return fmt.Sprintf( - "Authentication with %s failed. Check the API key, permissions, and billing settings.", - displayName, - ) + displayName := providerDisplayName(classified.Provider) + if displayName == "" { + displayName = "the AI provider" } - return "Authentication with the AI provider failed. Check the API key, permissions, and billing settings." + return fmt.Sprintf( + "Authentication with %s failed."+ + " Check the API key, permissions, and billing settings.", + displayName, + ) + case KindConfig: return fmt.Sprintf( - "%s rejected the model configuration. Check the selected model and provider settings.", + "%s rejected the model configuration."+ + " Check the selected model and provider settings.", subject, ) + default: if classified.StatusCode > 0 { - suffix := " Please try again." - if classified.Retryable { - suffix = " Please try again later." - } return fmt.Sprintf( - "%s returned an unexpected error (HTTP %d).%s", - subject, - classified.StatusCode, - suffix, + "%s returned an unexpected error (HTTP %d).", + subject, classified.StatusCode, ) } - if classified.Retryable { - return fmt.Sprintf( - "%s returned an unexpected error. Please try again later.", - subject, - ) + if !classified.Retryable { + return "The chat request failed unexpectedly." } - return "The chat request failed unexpectedly. Please try again." + return fmt.Sprintf("%s returned an unexpected error.", subject) } } -func optionalStatusMessage(subject string, statusCode int, withStatus string, withoutStatus string) string { - if statusCode > 0 { - return fmt.Sprintf(withStatus, subject, statusCode) +// retryMessage produces a clean factual description suitable for +// display alongside the retry countdown UI. It omits HTTP status +// codes (surfaced separately in the payload) and remediation +// guidance (not actionable while auto-retrying). +func retryMessage(classified ClassifiedError) string { + subject := providerSubject(classified.Provider) + switch classified.Kind { + case KindOverloaded: + return fmt.Sprintf("%s is temporarily overloaded.", subject) + case KindRateLimit: + return fmt.Sprintf("%s is rate limiting requests.", subject) + case KindTimeout: + return fmt.Sprintf("%s is temporarily unavailable.", subject) + case KindStartupTimeout: + return fmt.Sprintf( + "%s did not start responding in time.", subject, + ) + case KindAuth: + displayName := providerDisplayName(classified.Provider) + if displayName == "" { + displayName = "the AI provider" + } + return fmt.Sprintf( + "Authentication with %s failed.", displayName, + ) + case KindConfig: + return fmt.Sprintf( + "%s rejected the model configuration.", subject, + ) + default: + return fmt.Sprintf( + "%s returned an unexpected error.", subject, + ) } - return fmt.Sprintf(withoutStatus, subject) } func providerSubject(provider string) string { diff --git a/coderd/x/chatd/chaterror/payload.go b/coderd/x/chatd/chaterror/payload.go index 462cdba979..695c217ef1 100644 --- a/coderd/x/chatd/chaterror/payload.go +++ b/coderd/x/chatd/chaterror/payload.go @@ -30,7 +30,7 @@ func StreamRetryPayload( return &codersdk.ChatStreamRetry{ Attempt: attempt, DelayMs: delay.Milliseconds(), - Error: classified.Message, + Error: retryMessage(classified), Kind: classified.Kind, Provider: classified.Provider, StatusCode: classified.StatusCode, diff --git a/coderd/x/chatd/chaterror/payload_test.go b/coderd/x/chatd/chaterror/payload_test.go index 836a221c99..615df09e45 100644 --- a/coderd/x/chatd/chaterror/payload_test.go +++ b/coderd/x/chatd/chaterror/payload_test.go @@ -20,7 +20,7 @@ func TestStreamErrorPayloadUsesNormalizedClassification(t *testing.T) { payload := chaterror.StreamErrorPayload(classified) require.Equal(t, &codersdk.ChatStreamError{ - Message: "Azure OpenAI is rate limiting requests (HTTP 429). Please try again later.", + Message: "Azure OpenAI is rate limiting requests (HTTP 429).", Kind: chaterror.KindRateLimit, Provider: "azure", Retryable: true, @@ -40,7 +40,7 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) { delay := 3 * time.Second startedAt := time.Now() payload := chaterror.StreamRetryPayload(2, delay, chaterror.ClassifiedError{ - Message: "retry me", + Message: "OpenAI returned an unexpected error (HTTP 503).", Kind: chaterror.KindGeneric, Provider: "openai", Retryable: true, @@ -50,7 +50,9 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) { require.NotNil(t, payload) require.Equal(t, 2, payload.Attempt) require.Equal(t, delay.Milliseconds(), payload.DelayMs) - require.Equal(t, "retry me", payload.Error) + // Retry messages omit the HTTP status code; the status code is + // surfaced separately in the payload's StatusCode field. + require.Equal(t, "OpenAI returned an unexpected error.", payload.Error) require.Equal(t, chaterror.KindGeneric, payload.Kind) require.Equal(t, "openai", payload.Provider) require.Equal(t, 503, payload.StatusCode) diff --git a/coderd/x/chatd/chatloop/chatloop_test.go b/coderd/x/chatd/chatloop/chatloop_test.go index add8117302..05bf3bf181 100644 --- a/coderd/x/chatd/chatloop/chatloop_test.go +++ b/coderd/x/chatd/chatloop/chatloop_test.go @@ -144,7 +144,7 @@ func TestRun_OnRetryEnrichesProvider(t *testing.T) { require.Equal(t, 429, records[0].classified.StatusCode) require.Equal( t, - "OpenAI is rate limiting requests (HTTP 429). Please try again later.", + "OpenAI is rate limiting requests (HTTP 429).", records[0].classified.Message, ) } @@ -254,7 +254,7 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) { require.Equal(t, "openai", retries[0].Provider) require.Equal( t, - "OpenAI did not start responding in time. Please try again.", + "OpenAI did not start responding in time.", retries[0].Message, ) require.ErrorIs(t, <-attemptCause, errStartupTimeout) @@ -313,7 +313,7 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) { require.Equal(t, "openai", retries[0].Provider) require.Equal( t, - "OpenAI did not start responding in time. Please try again.", + "OpenAI did not start responding in time.", retries[0].Message, ) require.ErrorIs(t, <-attemptCause, errStartupTimeout) @@ -475,7 +475,7 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) { require.Equal(t, "openai", retries[0].Provider) require.Equal( t, - "OpenAI did not start responding in time. Please try again.", + "OpenAI did not start responding in time.", retries[0].Message, ) require.ErrorIs(t, <-attemptCause, errStartupTimeout) diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 8ac16e693f..bf238c7b7c 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -247,7 +247,7 @@ const getPersistedDetailError = ({ chatRecord: TypesGen.Chat | undefined; cachedError: ChatDetailError | undefined; }): ChatDetailError | undefined => { - if (cachedError?.kind === "usage-limit") { + if (cachedError?.kind === "usage_limit") { return cachedError; } if (chatStatus === "error") { @@ -595,7 +595,7 @@ const AgentDetail: FC = () => { isUsageLimitData(error.response.data) ) { const reason: ChatDetailError = { - kind: "usage-limit", + kind: "usage_limit", message: formatUsageLimitMessage(error.response.data), }; store.setStreamError(reason); diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ChatStatusCallout.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ChatStatusCallout.tsx index bc7596533f..f366072071 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ChatStatusCallout.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ChatStatusCallout.tsx @@ -4,7 +4,7 @@ import { Button } from "components/Button/Button"; import { Pill } from "components/Pill/Pill"; import { ExternalLinkIcon } from "lucide-react"; import { type FC, useEffect, useState } from "react"; -import { getProviderStatusURL } from "./chatStatusHelpers"; +import { getKindLabel, getProviderStatusURL } from "./chatStatusHelpers"; import type { LiveStatusModel } from "./liveStatusModel"; const RESPONSE_STARTUP_GRACE_MS = 15_000; @@ -130,9 +130,7 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => { : "warning"; const hasMetadata = status.phase === "retrying" || - status.provider !== undefined || - (status.phase === "failed" && status.statusCode !== undefined) || - (status.phase === "failed" && status.retryable !== undefined); + (status.phase === "failed" && status.statusCode !== undefined); return ( = ({ status }) => { className="h-5 px-2.5 text-[10px] font-semibold" type={pillType} > - {status.kind} + {getKindLabel(status.kind)} {status.message} @@ -171,13 +169,10 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => { {status.phase === "retrying" && ( Attempt {status.attempt} )} - {status.provider && Provider {status.provider}} + {status.phase === "failed" && status.statusCode !== undefined && ( HTTP {status.statusCode} )} - {status.phase === "failed" && status.retryable !== undefined && ( - {status.retryable ? "Retryable" : "Not retryable"} - )} )} diff --git a/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.stories.tsx index 09d9efd4d8..d4a94614eb 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.stories.tsx @@ -58,7 +58,7 @@ export const UsageLimitExceeded: Story = { ...defaultArgs, liveStatus: buildLiveStatus({ persistedError: { - kind: "usage-limit", + kind: "usage_limit", message: "You've used $50.00 of your $50.00 spend limit. Your limit resets on July 1, 2025.", }, @@ -80,7 +80,7 @@ export const TerminalOverloadedError: Story = { liveStatus: buildLiveStatus({ persistedError: { kind: "overloaded", - message: "Anthropic is currently overloaded. Please try again shortly.", + message: "Anthropic is currently overloaded.", provider: "anthropic", retryable: true, statusCode: 529, @@ -92,9 +92,46 @@ export const TerminalOverloadedError: Story = { expect( canvas.getByRole("heading", { name: /service overloaded/i }), ).toBeVisible(); - expect(canvas.getByText("overloaded")).toBeVisible(); + expect(canvas.getByText("Overloaded")).toBeVisible(); + expect( + canvas.getByText(/anthropic is currently overloaded./i), + ).toBeVisible(); + expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); + expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); expect(canvas.getByText(/http 529/i)).toBeVisible(); expect(canvas.getByRole("link", { name: /status/i })).toBeVisible(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); + }, +}; + +/** Terminal startup timeouts get a specific heading without provider metadata. */ +export const TerminalStartupTimeoutError: Story = { + args: { + ...defaultArgs, + liveStatus: buildLiveStatus({ + persistedError: { + kind: "startup_timeout", + message: "Anthropic did not start responding in time.", + provider: "anthropic", + retryable: true, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /startup timed out/i }), + ).toBeVisible(); + expect(canvas.getByText("Startup timeout")).toBeVisible(); + expect( + canvas.getByText(/anthropic did not start responding in time./i), + ).toBeVisible(); + expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); + expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx index 3b8404d347..fa715d9e7f 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx @@ -55,7 +55,7 @@ export const LiveStreamTailContent = ({ const shouldRenderStreamSection = shouldRenderStreamingSection(liveStatus); const terminalStatus = liveStatus.phase === "failed" ? liveStatus : null; const usageLimitStatus = - terminalStatus?.kind === "usage-limit" ? terminalStatus : null; + terminalStatus?.kind === "usage_limit" ? terminalStatus : null; const shouldRenderEmptyState = isTranscriptEmpty && liveStatus.phase === "idle"; diff --git a/site/src/pages/AgentsPage/components/AgentDetail/StreamingOutput.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetail/StreamingOutput.stories.tsx index 0115048d75..d32a9843fb 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/StreamingOutput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/StreamingOutput.stories.tsx @@ -71,13 +71,13 @@ export const ReconnectingAfterDisconnect: Story = { await waitFor(() => { expect(canvasElement.textContent).toMatch(/reconnecting in \d+s/i); }); - expect(canvas.queryByText("generic")).not.toBeInTheDocument(); + expect(canvas.queryByText("Unexpected error")).not.toBeInTheDocument(); const thinkingMatches = canvas.getAllByText(/thinking\.\.\./i); expect(thinkingMatches.length).toBeGreaterThanOrEqual(1); }, }; -/** Generic retry reasons show the mux-style retry callout. */ +/** Generic retry reasons show automatic retry copy without a manual CTA. */ export const RetryWithVisibleReason: Story = { args: { streamState: null, @@ -92,9 +92,13 @@ export const RetryWithVisibleReason: Story = { expect( canvas.getByRole("heading", { name: /retrying request/i }), ).toBeVisible(); - expect(canvas.getByText(/transient upstream failure/i)).toBeVisible(); - expect(canvas.getByText("generic")).toBeVisible(); + expect( + canvas.getByText(/anthropic returned an unexpected error/i), + ).toBeVisible(); + expect(canvas.getByText("Unexpected error")).toBeVisible(); expect(canvas.getByText(/attempt 1/i)).toBeVisible(); + expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); }, }; @@ -106,7 +110,7 @@ export const RetryRateLimited: Story = { liveStatus: buildLiveStatus({ retryState: buildRetryState({ attempt: 3, - error: "Anthropic asked us to back off briefly before retrying.", + error: "Anthropic is rate limiting requests.", kind: "rate_limit", delayMs: 3000, }), @@ -118,13 +122,17 @@ export const RetryRateLimited: Story = { expect( canvas.getByRole("heading", { name: /rate limited/i }), ).toBeVisible(); - expect(canvas.getByText("rate_limit")).toBeVisible(); + expect( + canvas.getByText(/anthropic is rate limiting requests/i), + ).toBeVisible(); + expect(canvas.getByText("Rate limit")).toBeVisible(); await waitFor(() => { expect(canvasElement.textContent).toMatch(/retrying in \d+s/i); }); expect( canvas.queryByRole("link", { name: /status/i }), ).not.toBeInTheDocument(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); }, }; @@ -136,7 +144,7 @@ export const RetryInvalidTimestamp: Story = { liveStatus: buildLiveStatus({ retryState: buildRetryState({ attempt: 3, - error: "Anthropic asked us to back off briefly before retrying.", + error: "Anthropic is rate limiting requests.", kind: "rate_limit", delayMs: 3000, retryingAt: "not-a-date", @@ -149,12 +157,16 @@ export const RetryInvalidTimestamp: Story = { expect( canvas.getByRole("heading", { name: /rate limited/i }), ).toBeVisible(); - expect(canvas.getByText("rate_limit")).toBeVisible(); + expect( + canvas.getByText(/anthropic is rate limiting requests/i), + ).toBeVisible(); + expect(canvas.getByText("Rate limit")).toBeVisible(); expect(canvas.getByText(/attempt 3/i)).toBeVisible(); await waitFor(() => { expect(canvas.queryByText(/retrying in nan/i)).not.toBeInTheDocument(); expect(canvas.queryByText(/retrying in \d+s/i)).not.toBeInTheDocument(); }); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); }, }; @@ -167,7 +179,7 @@ export const RetryOverloaded: Story = { retryState: buildRetryState({ kind: "overloaded", provider: "anthropic", - error: "Anthropic is currently overloaded. Retrying your request.", + error: "Anthropic is temporarily overloaded.", }), isAwaitingFirstStreamChunk: true, }), @@ -177,14 +189,18 @@ export const RetryOverloaded: Story = { expect( canvas.getByRole("heading", { name: /service overloaded/i }), ).toBeVisible(); - expect(canvas.getByText("overloaded")).toBeVisible(); + expect( + canvas.getByText(/anthropic is temporarily overloaded/i), + ).toBeVisible(); + expect(canvas.getByText("Overloaded")).toBeVisible(); const statusLink = screen.getByRole("link", { name: /status/i }); expect(statusLink).toBeVisible(); expect(statusLink).toHaveAttribute("href", "https://status.anthropic.com"); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); }, }; -/** Timeout retries render the timeout-specific heading without a status CTA. */ +/** Timeout retries render timeout-specific copy without a status CTA. */ export const RetryTimeout: Story = { args: { streamState: null, @@ -192,7 +208,7 @@ export const RetryTimeout: Story = { liveStatus: buildLiveStatus({ retryState: buildRetryState({ kind: "timeout", - error: "The provider took too long to respond. Retrying now.", + error: "Anthropic is temporarily unavailable.", }), isAwaitingFirstStreamChunk: true, }), @@ -200,9 +216,43 @@ export const RetryTimeout: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( - canvas.getByRole("heading", { name: /request time(?:out|d out)/i }), + canvas.getByRole("heading", { name: /request timed out/i }), ).toBeVisible(); - expect(canvas.getByText("timeout")).toBeVisible(); + expect( + canvas.getByText(/anthropic is temporarily unavailable/i), + ).toBeVisible(); + expect(canvas.getByText("Timeout")).toBeVisible(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); + }, +}; + +/** Startup timeouts explain the first-token delay before retrying. */ +export const RetryStartupTimeout: Story = { + args: { + streamState: null, + streamTools: [], + liveStatus: buildLiveStatus({ + retryState: buildRetryState({ + kind: "startup_timeout", + error: "Anthropic did not start responding in time.", + }), + isAwaitingFirstStreamChunk: true, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /startup timed out/i }), + ).toBeVisible(); + expect( + canvas.getByText(/anthropic did not start responding in time/i), + ).toBeVisible(); + expect(canvas.getByText("Startup timeout")).toBeVisible(); + expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); expect( canvas.queryByRole("link", { name: /status/i }), ).not.toBeInTheDocument(); diff --git a/site/src/pages/AgentsPage/components/AgentDetail/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/AgentDetail/chatStatusHelpers.ts index eb57273295..36ab6fb534 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/chatStatusHelpers.ts @@ -1,9 +1,43 @@ +import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage"; + const PROVIDER_STATUS_URLS: Record = { anthropic: "https://status.anthropic.com", }; +const normalizeProvider = (provider?: string): string | undefined => { + const normalized = provider?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + switch (normalized) { + case "azure openai": + case "azure-openai": + return "azure"; + case "openai compat": + case "openai compatible": + case "openai_compat": + return "openai-compat"; + default: + return normalized; + } +}; + +const humanizeKind = (kind: string): string => { + const words = kind + .trim() + .split(/[_\-\s]+/) + .filter(Boolean); + if (words.length === 0) { + return "Unexpected error"; + } + return words + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; + export const getErrorTitle = ( - kind: string, + kind: ChatProviderFailureKind | (string & {}), mode: "retry" | "error", ): string => { switch (kind) { @@ -12,18 +46,48 @@ export const getErrorTitle = ( case "rate_limit": return "Rate limited"; case "timeout": - return "Request timeout"; + return "Request timed out"; + case "startup_timeout": + return "Startup timed out"; + case "auth": + return "Authentication failed"; + case "config": + return "Configuration error"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } }; +export const getKindLabel = ( + kind: ChatProviderFailureKind | (string & {}), +): string => { + switch (kind) { + case "generic": + return "Unexpected error"; + case "overloaded": + return "Overloaded"; + case "rate_limit": + return "Rate limit"; + case "timeout": + return "Timeout"; + case "startup_timeout": + return "Startup timeout"; + case "auth": + return "Authentication"; + case "config": + return "Configuration"; + default: + return humanizeKind(kind); + } +}; + export const getProviderStatusURL = ( - kind: string, + kind: ChatProviderFailureKind | (string & {}), provider?: string, ): string | undefined => { - if (!provider || kind !== "overloaded") { + if (kind !== "overloaded") { return undefined; } - return PROVIDER_STATUS_URLS[provider.toLowerCase()]; + const normalized = normalizeProvider(provider); + return normalized ? PROVIDER_STATUS_URLS[normalized] : undefined; }; diff --git a/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.test.ts b/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.test.ts index 937ee38481..7e0a41afb7 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.test.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.test.ts @@ -15,7 +15,7 @@ const makeStreamState = ( const makeRetryState = (overrides: Partial = {}): RetryState => ({ attempt: 2, - error: "Retrying request shortly.", + error: "Anthropic returned an unexpected error.", kind: "generic", provider: "anthropic", delayMs: 2000, @@ -62,7 +62,7 @@ describe("deriveLiveStatus", () => { hasAccumulatedOutput: false, title: "Retrying request", kind: "generic", - message: "Retrying request shortly.", + message: "Anthropic returned an unexpected error.", attempt: 2, provider: "anthropic", delayMs: 2000, @@ -84,7 +84,6 @@ describe("deriveLiveStatus", () => { kind: "generic", message: "Chat processing failed.", provider: "anthropic", - retryable: false, statusCode: 500, }; diff --git a/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.ts b/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.ts index dae109a725..17260ec15b 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/liveStatusModel.ts @@ -37,7 +37,6 @@ export type LiveStatusModel = kind: string; message: string; provider?: string; - retryable?: boolean; statusCode?: number; } & LiveStatusBase); @@ -64,6 +63,21 @@ const toReconnectingLiveStatus = ( ...reconnectState, }); +const toRetryingLiveStatus = ( + retryState: RetryState, + options: { hasAccumulatedOutput?: boolean } = {}, +): Extract => ({ + phase: "retrying", + hasAccumulatedOutput: options.hasAccumulatedOutput ?? false, + title: getErrorTitle(retryState.kind, "retry"), + kind: retryState.kind, + message: retryState.error, + attempt: retryState.attempt, + provider: retryState.provider, + delayMs: retryState.delayMs, + retryingAt: retryState.retryingAt, +}); + const toFailedLiveStatus = ( error: ChatDetailError, options: { hasAccumulatedOutput?: boolean } = {}, @@ -74,7 +88,6 @@ const toFailedLiveStatus = ( kind: error.kind, message: error.message, provider: error.provider, - retryable: error.retryable, statusCode: error.statusCode, }); @@ -89,17 +102,7 @@ export const deriveLiveStatus = ({ const hasAccumulatedOutput = getHasAccumulatedOutput(streamState); if (retryState) { - return { - phase: "retrying", - hasAccumulatedOutput, - title: getErrorTitle(retryState.kind, "retry"), - kind: retryState.kind, - message: retryState.error, - attempt: retryState.attempt, - provider: retryState.provider, - delayMs: retryState.delayMs, - retryingAt: retryState.retryingAt, - }; + return toRetryingLiveStatus(retryState, { hasAccumulatedOutput }); } if (streamError) { diff --git a/site/src/pages/AgentsPage/components/AgentDetail/storyFixtures.ts b/site/src/pages/AgentsPage/components/AgentDetail/storyFixtures.ts index 515cedcca2..9f8565449a 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/storyFixtures.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/storyFixtures.ts @@ -73,8 +73,7 @@ export const buildRetryState = ( overrides: Partial = {}, ): RetryState => ({ attempt: 1, - error: - "Anthropic is retrying your request after a transient upstream failure.", + error: "Anthropic returned an unexpected error.", kind: "generic", provider: "anthropic", delayMs: 2000, diff --git a/site/src/pages/AgentsPage/components/AgentDetail/types.ts b/site/src/pages/AgentsPage/components/AgentDetail/types.ts index 55463e3be2..c3c89af9bb 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/types.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/types.ts @@ -1,5 +1,6 @@ import type { ReconnectSchedule } from "utils/reconnectingWebSocket"; import type * as TypesGen from "#/api/typesGenerated"; +import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage"; export type ParsedToolCall = { id: string; @@ -66,7 +67,7 @@ export type ReconnectState = ReconnectSchedule; export type RetryState = { attempt: number; error: string; - kind: string; + kind: ChatProviderFailureKind | (string & {}); provider?: string; delayMs?: number; retryingAt?: string; diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx index d4a0058afe..043ee048a1 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -206,13 +206,25 @@ export const WithError: Story = { ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /service overloaded/i }), + ).toBeVisible(); + expect( + canvas.getByText(/anthropic is currently overloaded\./i), + ).toBeVisible(); + expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); + expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); + expect(canvas.getByText(/http 529/i)).toBeVisible(); + }, }; /** Input area appears disabled when `isInputDisabled` is true. */ diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts index de41d566b5..70ea5b0e57 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts @@ -104,10 +104,10 @@ describe("isUsageLimitData", () => { it("accepts a fully populated valid payload", () => { const error: ChatDetailError = { message: "Your usage limit has been reached.", - kind: "usage-limit", + kind: "usage_limit", }; - expect(error.kind).toBe("usage-limit"); + expect(error.kind).toBe("usage_limit"); expect( isUsageLimitData({ spent_micros: 900_000, diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 00193a6eb3..8f1fe27315 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -10,21 +10,29 @@ interface UsageLimitData { resets_at?: string; // RFC3339 } +/** + * Known provider failure kinds surfaced in chat retry/error events. + */ +export type ChatProviderFailureKind = + | "generic" + | "overloaded" + | "rate_limit" + | "timeout" + | "startup_timeout" + | "auth" + | "config" + | "usage_limit"; + /** * Typed classification for errors surfaced in the agent detail view. - * - "usage-limit": the user hit a spending cap (409 + valid usage data). + * - "usage_limit": the user hit a spending cap (409 + valid usage data). * - other kinds come from normalized stream/provider failures such as - * "generic", "overloaded", "rate_limit", or "timeout". + * "generic", "overloaded", "rate_limit", "timeout", + * "startup_timeout", "auth", and "config". */ export type ChatDetailError = { message: string; - kind: - | "usage-limit" - | "generic" - | "overloaded" - | "rate_limit" - | "timeout" - | (string & {}); + kind: ChatProviderFailureKind | (string & {}); provider?: string; retryable?: boolean; statusCode?: number;