From 5b016f189b5b7c4a0a710875b44b5326493363a9 Mon Sep 17 00:00:00 2001 From: Timmy-Lane Date: Wed, 9 Sep 2026 16:47:39 +0500 Subject: [PATCH 1/3] Classify Claude auth failures and offer sign-in from the thread The Claude Code CLI reports an expired OAuth session on the assistant message (error: "authentication_failed"), but the turn's ProviderErrorInfo was built from the result message alone, whose "error_during_execution" subtype maps to category "unknown". error-display only titles a row when the category is known, so the user saw the CLI's apology sentence as an untitled error with no indication the account was the problem and no way to re-authenticate without leaving bb. Arm the assistant error code on the translator's per-turn state and consume it when the terminal provider.error is built, the same way a deferred hard rate-limit rejection already works. Surface the resulting "unauthorized" failure on the latest timeline page as providerAuthRequired, and render a prompt-stack banner whose button opens a thread terminal already running the provider's own loginCommand. --- .../banner/ProviderAuthBanner.test.tsx | 70 ++++++++ .../promptbox/banner/ProviderAuthBanner.tsx | 75 +++++++++ .../ThreadTimelinePanelContent.test.tsx | 1 + .../timeline/useThreadTimelineController.ts | 2 + .../app/src/test/fixtures/thread-responses.ts | 1 + ...ThreadDetailPromptArea.keystrokes.test.tsx | 1 + .../ThreadDetailPromptArea.test.tsx | 30 ++++ .../thread-detail/ThreadDetailPromptArea.tsx | 10 ++ .../views/thread-detail/ThreadDetailView.tsx | 101 +++++++++--- apps/server/src/services/threads/timeline.ts | 2 + packages/domain/src/index.ts | 1 + .../src/thread-timeline-provider-auth.ts | 8 + packages/server-contract/src/api/threads.ts | 2 + .../thread-view/src/build-thread-timeline.ts | 6 + .../src/provider-auth-extraction.ts | 24 +++ .../test/provider-auth-extraction.test.ts | 103 ++++++++++++ .../src/delta-translation.test.ts | 149 ++++++++++++++++++ .../src/delta-translation.ts | 23 +++ plugins/provider-claude-code/src/schemas.ts | 7 + 19 files changed, 593 insertions(+), 23 deletions(-) create mode 100644 apps/app/src/components/promptbox/banner/ProviderAuthBanner.test.tsx create mode 100644 apps/app/src/components/promptbox/banner/ProviderAuthBanner.tsx create mode 100644 packages/domain/src/thread-timeline-provider-auth.ts create mode 100644 packages/thread-view/src/provider-auth-extraction.ts create mode 100644 packages/thread-view/test/provider-auth-extraction.test.ts diff --git a/apps/app/src/components/promptbox/banner/ProviderAuthBanner.test.tsx b/apps/app/src/components/promptbox/banner/ProviderAuthBanner.test.tsx new file mode 100644 index 00000000000..3f7ed629f25 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ProviderAuthBanner.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProviderAuthBanner } from "./ProviderAuthBanner"; + +afterEach(() => { + cleanup(); +}); + +describe("ProviderAuthBanner", () => { + it("offers a sign-in action for the failing provider", () => { + const onSignIn = vi.fn(); + render( + , + ); + + expect( + screen.getByRole("region", { name: "Claude Code sign-in required" }), + ).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toContain( + "Sign in again to continue this thread.", + ); + + fireEvent.click( + screen.getByRole("button", { name: "Sign in to Claude Code" }), + ); + expect(onSignIn).toHaveBeenCalledOnce(); + }); + + it("falls back to the login command when no terminal can be opened", () => { + render( + , + ); + + expect(screen.getByRole("alert").textContent).toContain( + "Run claude /login where this thread runs, then send again.", + ); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("disables the action while the sign-in terminal is opening", () => { + render( + , + ); + + expect( + (screen.getByRole("button", { name: "Opening…" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); +}); diff --git a/apps/app/src/components/promptbox/banner/ProviderAuthBanner.tsx b/apps/app/src/components/promptbox/banner/ProviderAuthBanner.tsx new file mode 100644 index 00000000000..1f22009ba82 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ProviderAuthBanner.tsx @@ -0,0 +1,75 @@ +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; + +export interface ThreadPromptProviderAuthSection { + displayName: string; + loginCommand: string | null; + canSignIn: boolean; + signingIn: boolean; + onSignIn: () => void; +} + +function signInCopy(loginCommand: string | null, canSignIn: boolean): string { + if (canSignIn) { + return "Sign in again to continue this thread."; + } + if (loginCommand !== null) { + return `Run ${loginCommand} where this thread runs, then send again.`; + } + return "Sign in again where this thread runs, then send again."; +} + +export function ProviderAuthBanner({ + displayName, + loginCommand, + canSignIn, + signingIn, + onSignIn, +}: ThreadPromptProviderAuthSection) { + return ( + +
+ + + +
+

+ {displayName} sign-in required +

+

+ The last turn failed because the {displayName} session is no longer + authorized. {signInCopy(loginCommand, canSignIn)} +

+
+ {canSignIn ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx index 1b54f485c71..bcdb8a3d24c 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx @@ -97,6 +97,7 @@ function baseTimeline( overrides: Partial = {}, ): UseThreadTimelineControllerResult { return { + providerAuthRequired: null, activePromptMode: null, activeThinking: null, activeWorkflows: [], diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index b76be542a35..1f48f1272ba 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -32,6 +32,7 @@ export interface UseThreadTimelineControllerResult { isLoadingOlderTimelineRows: boolean; loadOlderTimelineRows: () => Promise; pendingTodos: ThreadTimelineResponse["pendingTodos"]; + providerAuthRequired: ThreadTimelineResponse["providerAuthRequired"]; timelineError: Error | null; timelineLoading: boolean; timelineRows: TimelineRow[]; @@ -212,6 +213,7 @@ export function useThreadTimelineController({ isLoadingOlderTimelineRows, loadOlderTimelineRows, pendingTodos: latestTimeline?.pendingTodos ?? null, + providerAuthRequired: latestTimeline?.providerAuthRequired ?? null, timelineError, timelineLoading, timelineRows, diff --git a/apps/app/src/test/fixtures/thread-responses.ts b/apps/app/src/test/fixtures/thread-responses.ts index e11f181d1d8..abf3fa9194a 100644 --- a/apps/app/src/test/fixtures/thread-responses.ts +++ b/apps/app/src/test/fixtures/thread-responses.ts @@ -41,6 +41,7 @@ export function makeThreadTimelineResponse( pendingTodos: null, goal: null, modelFallback: null, + providerAuthRequired: null, maxSeq: 0, timelinePage: { kind: "latest", diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx index 1341a9db002..43ec83ff738 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -373,6 +373,7 @@ function buildPromptArea({ environmentGoneStatus={null} goal={null} modelFallback={null} + providerAuthSection={null} isEnvironmentActionPending={false} onChangedFileClick={vi.fn()} parentThreadSection={null} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 1e464d5b02d..e0f5abe5c4c 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -36,6 +36,7 @@ import { setPluginSlotRegistrations, } from "@/lib/plugin-slots"; import type { ChildThreadPendingAttention } from "@/hooks/queries/child-thread-pending-interactions"; +import type { ThreadPromptProviderAuthSection } from "@/components/promptbox/banner/ProviderAuthBanner"; import { ThreadDetailPromptArea, type ThreadDetailSentMessageEdit, @@ -692,6 +693,7 @@ interface RenderPromptAreaOptions { activeWorkflows?: TimelineWorkflowWorkRow[]; goal?: ThreadTimelineGoal | null; modelFallback?: ThreadTimelineModelFallback | null; + providerAuthSection?: ThreadPromptProviderAuthSection | null; pendingInteractions?: readonly PendingInteraction[]; childPendingInteractions?: readonly ChildThreadPendingAttention[]; pendingInteractionsInitialLoading?: boolean; @@ -705,6 +707,7 @@ function buildPromptAreaElement({ activeWorkflows = [], goal = null, modelFallback = null, + providerAuthSection = null, pendingInteractions = [], childPendingInteractions = [], pendingInteractionsInitialLoading = false, @@ -726,6 +729,7 @@ function buildPromptAreaElement({ environmentGoneStatus={null} goal={goal} modelFallback={modelFallback} + providerAuthSection={providerAuthSection} isEnvironmentActionPending={false} onChangedFileClick={vi.fn()} parentThreadSection={null} @@ -1769,4 +1773,30 @@ describe("ThreadDetailPromptArea", () => { }, }); }); + + it("offers provider sign-in above the composer when the last turn was unauthorized", () => { + const onSignIn = vi.fn(); + renderPromptArea({ + providerAuthSection: { + displayName: "Claude Code", + loginCommand: "claude /login", + canSignIn: true, + signingIn: false, + onSignIn, + }, + }); + + fireEvent.click( + screen.getByRole("button", { name: "Sign in to Claude Code" }), + ); + expect(onSignIn).toHaveBeenCalledOnce(); + }); + + it("shows no sign-in banner while the provider is authorized", () => { + renderPromptArea(); + + expect( + screen.queryByRole("region", { name: "Claude Code sign-in required" }), + ).toBeNull(); + }); }); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 9f229783d88..05882161459 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -53,6 +53,10 @@ import { ThreadPromptModeCard } from "@/components/promptbox/banner/ThreadPrompt import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflowCard"; import { ThreadBackgroundCommandsCard } from "@/components/promptbox/banner/ThreadBackgroundCommandsCard"; import { ThreadModelFallbackCard } from "@/components/promptbox/banner/ThreadModelFallbackCard"; +import { + ProviderAuthBanner, + type ThreadPromptProviderAuthSection, +} from "@/components/promptbox/banner/ProviderAuthBanner"; import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame"; import type { WorkspaceChangedFileSelection, @@ -172,6 +176,7 @@ interface ThreadDetailPromptAreaProps { activePromptMode: ThreadTimelineActivePromptMode | null; goal: ThreadTimelineGoal | null; modelFallback: ThreadTimelineModelFallback | null; + providerAuthSection: ThreadPromptProviderAuthSection | null; activeWorkflows: TimelineWorkflowWorkRow[]; activeBackgroundCommands: TimelineWorkflowWorkRow[]; parentThreadSection: ThreadPromptParentThreadSection | null; @@ -365,6 +370,7 @@ export function ThreadDetailPromptArea({ activePromptMode, goal, modelFallback, + providerAuthSection, activeWorkflows, activeBackgroundCommands, parentThreadSection, @@ -1597,6 +1603,9 @@ export function ThreadDetailPromptArea({ threadId={thread.id} /> ) : null} + {providerAuthSection ? ( + + ) : null} {shouldHideComposer ? null : queuedMessagesPending ? ( ) : ( @@ -1651,6 +1660,7 @@ export function ThreadDetailPromptArea({ activeBackgroundCommands, isBackgroundCommandsExpanded, modelFallback, + providerAuthSection, parentThreadSection, childThreadsSection, pullRequestSection, diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 24bf935384e..bd3e8b79e47 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -7,7 +7,10 @@ import { type ReactNode, } from "react"; import { nanoid } from "nanoid"; -import { useSystemProviderInfo } from "@/hooks/queries/system-queries"; +import { + useSystemProviderInfo, + useSystemProviderStates, +} from "@/hooks/queries/system-queries"; import { useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { desktopBrowserRevealAtom } from "@/lib/desktop-browser-presentation"; @@ -36,6 +39,7 @@ import { type ThreadWithRuntime, } from "@bb/domain"; import type { + CreateTerminalRequest, PullRequestMergeMethod, TerminalSession, TimelineRow, @@ -861,6 +865,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { loadOlderTimelineRows, modelFallback, pendingTodos, + providerAuthRequired, timelineError, timelineLoading, timelineRows, @@ -971,6 +976,17 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { providerId: thread?.providerId, }, ); + const providerStatesQuery = useSystemProviderStates({ + enabled: providerAuthRequired !== null, + ...(thread?.environmentId ? { environmentId: thread.environmentId } : {}), + poll: false, + }); + const providerAuthProviderState = + providerAuthRequired === null + ? null + : (providerStatesQuery.data?.providers.find( + (provider) => provider.providerId === thread?.providerId, + ) ?? null); const threadProviderPluginId = threadProviderInfo?.pluginId ?? null; const threadProviderContextValue = useMemo( () => ({ @@ -1619,31 +1635,69 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { } return desktopInfo.onOpenNewTab(handleOpenNewTab); }, [handleOpenNewTab, isFocused]); - const handleStartTerminal = useCallback(() => { - if (!canCreateTerminal || createTerminal.isPending || !threadId) { - return; + const handleStartTerminal = useCallback( + (start?: CreateTerminalRequest["start"]) => { + if (!canCreateTerminal || createTerminal.isPending || !threadId) { + return; + } + const newTab = createNewTabFixedPanelTab(); + void createTerminal + .mutateAsync({ + threadId, + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + ...(start === undefined ? {} : { start }), + }) + .then((session) => { + closeTab(newTab.id); + setShouldAutoFocusTerminal(true); + setActiveFixedTerminal(session.id); + openCompactDrawer(); + }) + .catch(() => undefined); + }, + [ + canCreateTerminal, + closeTab, + createTerminal, + openCompactDrawer, + setActiveFixedTerminal, + threadId, + ], + ); + const providerAuthLoginCommand = + providerAuthProviderState?.loginCommand ?? null; + const providerAuthSection = useMemo(() => { + if (providerAuthRequired === null || thread === undefined) { + return null; } - const newTab = createNewTabFixedPanelTab(); - void createTerminal - .mutateAsync({ - threadId, - cols: DEFAULT_TERMINAL_COLS, - rows: DEFAULT_TERMINAL_ROWS, - }) - .then((session) => { - closeTab(newTab.id); - setShouldAutoFocusTerminal(true); - setActiveFixedTerminal(session.id); - openCompactDrawer(); - }) - .catch(() => undefined); + return { + displayName: + providerAuthProviderState?.displayName ?? + threadProviderInfo?.displayName ?? + thread.providerId, + loginCommand: providerAuthLoginCommand, + canSignIn: canCreateTerminal && providerAuthLoginCommand !== null, + signingIn: createTerminal.isPending, + onSignIn: () => { + if (providerAuthLoginCommand === null) { + return; + } + handleStartTerminal({ + mode: "command", + command: providerAuthLoginCommand, + }); + }, + }; }, [ canCreateTerminal, - closeTab, - createTerminal, - openCompactDrawer, - setActiveFixedTerminal, - threadId, + createTerminal.isPending, + handleStartTerminal, + providerAuthLoginCommand, + providerAuthProviderState?.displayName, + providerAuthRequired, + thread, + threadProviderInfo?.displayName, ]); useAppCommandHandler("terminal.open", () => { if ( @@ -2568,6 +2622,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { activePromptMode={activePromptMode} goal={goal} modelFallback={modelFallback} + providerAuthSection={providerAuthSection} activeWorkflows={activeWorkflows} activeBackgroundCommands={activeBackgroundCommands} parentThreadSection={parentThreadSection} diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts index a3d8212b7e8..21e1563fae4 100644 --- a/apps/server/src/services/threads/timeline.ts +++ b/apps/server/src/services/threads/timeline.ts @@ -1795,6 +1795,8 @@ function buildThreadTimelineInternal( goal: timeline.goal, modelFallback: options.page.kind === "latest" ? timeline.modelFallback : null, + providerAuthRequired: + options.page.kind === "latest" ? timeline.providerAuthRequired : null, contextWindowUsage: options.page.kind === "latest" ? (timeline.contextWindowUsage ?? undefined) diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ea043c3ee6f..445a198b6c9 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -59,5 +59,6 @@ export * from "./thread-timeline-active-prompt-mode.js"; export * from "./thread-timeline-goal.js"; export * from "./thread-timeline-model-fallback.js"; export * from "./thread-timeline-pending-todos.js"; +export * from "./thread-timeline-provider-auth.js"; export * from "./thread-visibility.js"; export * from "./thread.js"; diff --git a/packages/domain/src/thread-timeline-provider-auth.ts b/packages/domain/src/thread-timeline-provider-auth.ts new file mode 100644 index 00000000000..52b07444bad --- /dev/null +++ b/packages/domain/src/thread-timeline-provider-auth.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const threadTimelineProviderAuthRequiredSchema = z.object({ + sourceSeq: z.number().int().nonnegative(), +}); +export type ThreadTimelineProviderAuthRequired = z.infer< + typeof threadTimelineProviderAuthRequiredSchema +>; diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 8bd31c2426e..d914a88962c 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -24,6 +24,7 @@ import { threadTimelineActivePromptModeSchema, threadTimelineGoalSchema, threadTimelineModelFallbackSchema, + threadTimelineProviderAuthRequiredSchema, threadTimelinePendingTodosSchema, threadEventTypeValues, threadVisibilitySchema, @@ -947,6 +948,7 @@ export const threadTimelineResponseSchema = z.object({ pendingTodos: threadTimelinePendingTodosSchema.nullable(), goal: threadTimelineGoalSchema.nullable(), modelFallback: threadTimelineModelFallbackSchema.nullable(), + providerAuthRequired: threadTimelineProviderAuthRequiredSchema.nullable(), contextWindowUsage: threadContextWindowUsageSchema.optional(), timelinePage: timelinePageMetadataSchema, maxSeq: z.number().int().nonnegative(), diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index d79badb5c03..20eeaf529e0 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -23,6 +23,7 @@ import { type ThreadTimelineActivePromptMode, type ThreadTimelineGoal, type ThreadTimelineModelFallback, + type ThreadTimelineProviderAuthRequired, type ThreadTimelinePendingTodos, } from "@bb/domain"; import type { @@ -67,6 +68,7 @@ import { } from "./active-prompt-mode-extraction.js"; import { extractThreadTimelineGoal } from "./goal-snapshot-extraction.js"; import { extractThreadTimelineModelFallback } from "./model-fallback-extraction.js"; +import { extractThreadTimelineProviderAuthRequired } from "./provider-auth-extraction.js"; import { extractThreadTimelinePendingTodos } from "./todo-snapshot-extraction.js"; import { buildTimelineErrorDisplay } from "./error-display.js"; @@ -105,6 +107,7 @@ export interface ThreadTimelineFromEventsResult { goal: ThreadTimelineGoal | null; modelFallback: ThreadTimelineModelFallback | null; pendingTodos: ThreadTimelinePendingTodos | null; + providerAuthRequired: ThreadTimelineProviderAuthRequired | null; rows: TimelineRow[]; } @@ -1413,6 +1416,9 @@ export function buildThreadTimelineFromEvents( args.options.threadStatus, args.events, ), + providerAuthRequired: !args.options.isLatestPage + ? null + : extractThreadTimelineProviderAuthRequired(args.events), rows, }; } diff --git a/packages/thread-view/src/provider-auth-extraction.ts b/packages/thread-view/src/provider-auth-extraction.ts new file mode 100644 index 00000000000..c15f8dc835a --- /dev/null +++ b/packages/thread-view/src/provider-auth-extraction.ts @@ -0,0 +1,24 @@ +import type { ThreadTimelineProviderAuthRequired } from "@bb/domain"; +import type { ThreadEventWithMeta } from "./group-event-projection-turns.js"; + +export function extractThreadTimelineProviderAuthRequired( + events: readonly ThreadEventWithMeta[], +): ThreadTimelineProviderAuthRequired | null { + let authRequired: ThreadTimelineProviderAuthRequired | null = null; + + for (const { event, meta } of events) { + if (event.type === "client/turn/requested") { + authRequired = null; + continue; + } + if ( + event.type === "provider/error" && + event.errorInfo?.category === "unauthorized" && + event.willRetry !== true + ) { + authRequired = { sourceSeq: meta.seq }; + } + } + + return authRequired; +} diff --git a/packages/thread-view/test/provider-auth-extraction.test.ts b/packages/thread-view/test/provider-auth-extraction.test.ts new file mode 100644 index 00000000000..cfb55b48191 --- /dev/null +++ b/packages/thread-view/test/provider-auth-extraction.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { extractThreadTimelineProviderAuthRequired } from "../src/provider-auth-extraction.js"; +import type { ThreadEventWithMeta } from "../src/group-event-projection-turns.js"; + +function event( + sequence: number, + value: ThreadEventWithMeta["event"], +): ThreadEventWithMeta { + return { + event: value, + meta: { id: `event-${sequence}`, seq: sequence, createdAt: sequence * 10 }, + }; +} + +function providerError( + category: "unauthorized" | "rate-limit", + options: { willRetry?: boolean } = {}, +): ThreadEventWithMeta["event"] { + return { + type: "provider/error", + threadId: "thread-1", + providerThreadId: "session-1", + scope: { kind: "turn", turnId: "turn-1" }, + message: "Provider error", + detail: "Failed to authenticate: OAuth session expired", + ...(options.willRetry === undefined + ? {} + : { willRetry: options.willRetry }), + errorInfo: { + category, + providerCode: "authentication_failed", + httpStatusCode: null, + }, + }; +} + +function turnRequested(): ThreadEventWithMeta["event"] { + return { + type: "client/turn/requested", + threadId: "thread-1", + scope: { kind: "thread" }, + direction: "outbound", + requestId: "req_test0001", + source: "tell", + initiator: "user", + senderThreadId: null, + input: [{ type: "text", text: "continue", mentions: [] }], + target: { kind: "new-turn" }, + request: { method: "turn/start", params: {} }, + execution: { + model: "claude-opus-4-8", + serviceTier: "default", + reasoningLevel: "medium", + permissionMode: "full", + source: "client/turn/requested", + }, + }; +} + +describe("extractThreadTimelineProviderAuthRequired", () => { + it("reports the sequence of a terminal unauthorized provider error", () => { + expect( + extractThreadTimelineProviderAuthRequired([ + event(4, providerError("unauthorized")), + ]), + ).toEqual({ sourceSeq: 4 }); + }); + + it("ignores provider errors of other categories", () => { + expect( + extractThreadTimelineProviderAuthRequired([ + event(4, providerError("rate-limit")), + ]), + ).toBeNull(); + }); + + it("ignores an unauthorized error the provider will retry", () => { + expect( + extractThreadTimelineProviderAuthRequired([ + event(4, providerError("unauthorized", { willRetry: true })), + ]), + ).toBeNull(); + }); + + it("clears once the user starts another turn", () => { + expect( + extractThreadTimelineProviderAuthRequired([ + event(4, providerError("unauthorized")), + event(5, turnRequested()), + ]), + ).toBeNull(); + }); + + it("reports again when the retried turn fails the same way", () => { + expect( + extractThreadTimelineProviderAuthRequired([ + event(4, providerError("unauthorized")), + event(5, turnRequested()), + event(6, providerError("unauthorized")), + ]), + ).toEqual({ sourceSeq: 6 }); + }); +}); diff --git a/plugins/provider-claude-code/src/delta-translation.test.ts b/plugins/provider-claude-code/src/delta-translation.test.ts index 97470be7e47..b67cc1b5f01 100644 --- a/plugins/provider-claude-code/src/delta-translation.test.ts +++ b/plugins/provider-claude-code/src/delta-translation.test.ts @@ -1910,6 +1910,155 @@ describe("claude error translation", () => { ); }); + it("classifies an expired Claude OAuth session as unauthorized", () => { + const harness = createClaudeDeltaHarness(); + + harness.translate({ + jsonrpc: "2.0", + method: "sdk/message", + params: { + threadId: "claude-thread-1", + message: { + type: "assistant", + error: "authentication_failed", + isApiErrorMessage: true, + message: { + id: "assistant-1", + content: [ + { + type: "text", + text: "Failed to authenticate: OAuth session expired and could not be refreshed", + }, + ], + model: "", + stop_reason: "stop_sequence", + stop_sequence: "", + }, + }, + }, + }); + + const events = harness.translate({ + jsonrpc: "2.0", + method: "sdk/message", + params: { + threadId: "claude-thread-1", + message: { + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: [ + "Failed to authenticate: OAuth session expired and could not be refreshed", + ], + usage: {}, + modelUsage: {}, + }, + }, + }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider/error", + scope: turnScope(TURN_1), + message: "Provider error", + errorInfo: { + category: "unauthorized", + providerCode: "authentication_failed", + httpStatusCode: null, + }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn/completed", + scope: turnScope(TURN_1), + status: "failed", + }), + ); + }); + + it("does not carry an assistant error code into a later successful turn", () => { + const harness = createClaudeDeltaHarness(); + + harness.translate({ + jsonrpc: "2.0", + method: "sdk/message", + params: { + threadId: "claude-thread-1", + message: { + type: "assistant", + error: "authentication_failed", + isApiErrorMessage: true, + message: { + id: "assistant-1", + content: [ + { + type: "text", + text: "Failed to authenticate: OAuth session expired and could not be refreshed", + }, + ], + model: "", + stop_reason: "stop_sequence", + stop_sequence: "", + }, + }, + }, + }); + harness.translate({ + jsonrpc: "2.0", + method: "sdk/message", + params: { + threadId: "claude-thread-1", + message: { + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["Failed to authenticate"], + usage: {}, + modelUsage: {}, + }, + }, + }); + harness.acceptInput("creq_23456789af"); + harness.translate({ + jsonrpc: "2.0", + method: "sdk/message", + params: { + threadId: "claude-thread-1", + message: { + type: "assistant", + message: { id: "assistant-2", content: [] }, + }, + }, + }); + + const events = harness.translate({ + jsonrpc: "2.0", + method: "sdk/message", + params: { + threadId: "claude-thread-1", + message: { + type: "result", + subtype: "error_max_turns", + is_error: true, + errors: ["Reached the maximum number of turns"], + usage: {}, + modelUsage: {}, + }, + }, + }); + + expect(providerErrors(events)).toEqual([ + expect.objectContaining({ + errorInfo: { + category: "max-turns", + providerCode: "error_max_turns", + httpStatusCode: null, + }, + }), + ]); + }); + it("preserves unknown Claude rate limit window keys", () => { const harness = createClaudeDeltaHarness(); diff --git a/plugins/provider-claude-code/src/delta-translation.ts b/plugins/provider-claude-code/src/delta-translation.ts index 7ec97f88cf7..999aa65052d 100644 --- a/plugins/provider-claude-code/src/delta-translation.ts +++ b/plugins/provider-claude-code/src/delta-translation.ts @@ -24,6 +24,7 @@ import { } from "@get-bb/plugin-sdk/provider-bridge"; import { claudeApiRetryMessageSchema, + claudeAssistantErrorMessageSchema, claudeAssistantMessageSchema, claudeBackgroundTasksChangedMessageSchema, claudeCompactBoundarySystemMessageSchema, @@ -40,6 +41,7 @@ import { claudeUserMessageSchema, type ClaudeApiRetryMessage, type ClaudeAssistantMessage, + type ClaudeAssistantMessageError, type ClaudeRateLimitEvent, type ClaudeResultMessage, } from "./schemas.js"; @@ -388,6 +390,9 @@ interface ClaudeThreadDialectState { | (ClaudeModelFallbackTransition & { segment: number }) | undefined; armedHardRateLimitRejection: { detail: string; segment: number } | undefined; + armedAssistantErrorCode: + | { code: ClaudeAssistantMessageError; segment: number } + | undefined; selectedModelContextWindow: number | null; suppressUnacceptedTurnStart: boolean; openCompaction: { segment: number } | undefined; @@ -405,6 +410,7 @@ function createThreadState(): ClaudeThreadDialectState { latestProviderCheckpointId: undefined, lastModelFallback: undefined, armedHardRateLimitRejection: undefined, + armedAssistantErrorCode: undefined, selectedModelContextWindow: null, suppressUnacceptedTurnStart: false, openCompaction: undefined, @@ -454,12 +460,14 @@ export function createClaudeDeltaTranslator( state.latestRequestContextTokens = undefined; state.latestProviderCheckpointId = undefined; state.armedHardRateLimitRejection = undefined; + state.armedAssistantErrorCode = undefined; state.startedTools.clear(); } function mirrorCloseTurn(state: ClaudeThreadDialectState): void { state.mirror.turnOpen = false; state.armedHardRateLimitRejection = undefined; + state.armedAssistantErrorCode = undefined; state.startedTools.clear(); } @@ -842,6 +850,13 @@ export function createClaudeDeltaTranslator( if (providerCheckpointId !== undefined) { state.latestProviderCheckpointId = providerCheckpointId; } + const assistantError = claudeAssistantErrorMessageSchema.safeParse(event); + if (assistantError.success) { + state.armedAssistantErrorCode = { + code: assistantError.data.error, + segment: state.mirror.segment, + }; + } const requestContextTokens = extractClaudeRequestContextTokens(message); if (requestContextTokens !== null) { state.latestRequestContextTokens = requestContextTokens; @@ -1066,12 +1081,19 @@ export function createClaudeDeltaTranslator( state.mirror.turnOpen ? state.armedHardRateLimitRejection : undefined; + const armedAssistantErrorCode = + state.armedAssistantErrorCode?.segment === state.mirror.segment + ? state.armedAssistantErrorCode.code + : undefined; const resultFailed = isClaudeResultFailure(message); const failed = resultFailed || pendingHardRateLimitRejection !== undefined; if (failed) { const resultErrorInfo = buildClaudeProviderErrorInfo({ httpStatusCode: message.api_error_status, resultSubtype: message.subtype, + ...(armedAssistantErrorCode === undefined + ? {} + : { code: armedAssistantErrorCode }), }); const errorInfo = pendingHardRateLimitRejection === undefined @@ -1092,6 +1114,7 @@ export function createClaudeDeltaTranslator( }); } state.armedHardRateLimitRejection = undefined; + state.armedAssistantErrorCode = undefined; if (!failed && hasCompletionBlockingClaudeTasks(state.tasksById)) { return deltas; } diff --git a/plugins/provider-claude-code/src/schemas.ts b/plugins/provider-claude-code/src/schemas.ts index 620291e4a49..57e8b076906 100644 --- a/plugins/provider-claude-code/src/schemas.ts +++ b/plugins/provider-claude-code/src/schemas.ts @@ -138,6 +138,13 @@ export type ClaudeAssistantMessageError = z.infer< typeof claudeAssistantMessageErrorSchema >; +export const claudeAssistantErrorMessageSchema = z + .object({ + type: z.literal("assistant"), + error: claudeAssistantMessageErrorSchema, + }) + .passthrough(); + export const claudeSdkMessageTypeSchema = z .object({ type: z.enum([ From cfdb1f85e106bc63304f56a4a64d0a29fb0a3905 Mon Sep 17 00:00:00 2001 From: t1mdurden Date: Wed, 9 Sep 2026 17:19:59 +0500 Subject: [PATCH 2/3] Add providerAuthRequired to the remaining timeline response literals Every full ThreadTimelineResponse literal outside the server has to carry the new field: the demo world, and the CLI, client-core, server and integration fixtures. --- apps/cli/src/__tests__/helpers/command-output-fixtures.ts | 1 + apps/cli/src/commands/thread/pending-todos.test.ts | 1 + apps/demo-server/src/demo-world.ts | 1 + apps/server/test/services/threads/timeline-cache.test.ts | 1 + .../test/services/threads/timeline-output-truncation.test.ts | 1 + packages/client-core/test/timeline-merge.test.ts | 1 + tests/integration/fake/smoke/timeline-response.test.ts | 1 + 7 files changed, 7 insertions(+) diff --git a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts index b9e92234c3e..5a892848ad1 100644 --- a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts +++ b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts @@ -74,6 +74,7 @@ export function makeTimelineResponse( pendingTodos: null, goal: null, modelFallback: null, + providerAuthRequired: null, maxSeq: 0, timelinePage: { kind: "latest", diff --git a/apps/cli/src/commands/thread/pending-todos.test.ts b/apps/cli/src/commands/thread/pending-todos.test.ts index ed5d4526b58..de0cd234607 100644 --- a/apps/cli/src/commands/thread/pending-todos.test.ts +++ b/apps/cli/src/commands/thread/pending-todos.test.ts @@ -113,6 +113,7 @@ describe("fetchThreadPendingTodos", () => { pendingTodos, goal: null, modelFallback: null, + providerAuthRequired: null, rows: [], maxSeq: 0, timelinePage: { diff --git a/apps/demo-server/src/demo-world.ts b/apps/demo-server/src/demo-world.ts index 0d2c5484e0d..0aba78e1006 100644 --- a/apps/demo-server/src/demo-world.ts +++ b/apps/demo-server/src/demo-world.ts @@ -410,6 +410,7 @@ export class DemoWorld { pendingTodos: null, goal: null, modelFallback: null, + providerAuthRequired: null, contextWindowUsage: { estimated: false, modelContextWindow: 258_400, diff --git a/apps/server/test/services/threads/timeline-cache.test.ts b/apps/server/test/services/threads/timeline-cache.test.ts index 5f7b40d872d..0d00da790a3 100644 --- a/apps/server/test/services/threads/timeline-cache.test.ts +++ b/apps/server/test/services/threads/timeline-cache.test.ts @@ -31,6 +31,7 @@ function makeResponse(rowCount: number): ThreadTimelineResponse { pendingTodos: null, goal: null, modelFallback: null, + providerAuthRequired: null, maxSeq: 0, timelinePage: { kind: "latest", diff --git a/apps/server/test/services/threads/timeline-output-truncation.test.ts b/apps/server/test/services/threads/timeline-output-truncation.test.ts index d114fb5296a..f903bfdeee7 100644 --- a/apps/server/test/services/threads/timeline-output-truncation.test.ts +++ b/apps/server/test/services/threads/timeline-output-truncation.test.ts @@ -26,6 +26,7 @@ function response(rows: TimelineRow[]): ThreadTimelineResponse { pendingTodos: null, goal: null, modelFallback: null, + providerAuthRequired: null, maxSeq: 0, timelinePage: { kind: "latest", diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts index 55139d78f45..dcda85951d9 100644 --- a/packages/client-core/test/timeline-merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -119,6 +119,7 @@ function makeTimelineResponse( pendingTodos: null, goal: null, modelFallback: null, + providerAuthRequired: null, maxSeq, timelinePage: { kind: "latest", diff --git a/tests/integration/fake/smoke/timeline-response.test.ts b/tests/integration/fake/smoke/timeline-response.test.ts index 194af0f4fe6..7295d9a6223 100644 --- a/tests/integration/fake/smoke/timeline-response.test.ts +++ b/tests/integration/fake/smoke/timeline-response.test.ts @@ -53,6 +53,7 @@ function makeTimelineResponse( pendingTodos: null, goal: null, modelFallback: null, + providerAuthRequired: null, maxSeq: 0, timelinePage: { kind: "latest", From 7d968ea8429d5d7130b6649e9a2a449a7ec07355 Mon Sep 17 00:00:00 2001 From: t1mdurden Date: Wed, 9 Sep 2026 17:28:05 +0500 Subject: [PATCH 3/3] Refresh the claude-code auth-failure parity lane The recorded session now replays with errorInfo on its provider.error: category "unauthorized", providerCode "authentication_failed". Regenerated with `pnpm --filter @bb/provider-parity run rerecord --provider claude-code --cell auth-failure`; the session id churn that rerecord produces is reverted so the lane diff is the one line that changed. --- .../auth-failure/bridge\342\206\222runtime.current.ndjson" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" "b/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" index 8c04864a41b..69d2c2b02f3 100644 --- "a/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" +++ "b/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" @@ -6,4 +6,4 @@ {"ts":1787279795704,"run":1787279794200,"seq":10.272727272727273,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/identity\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"providerThreadId\":\"54386450-a88e-4d9e-b4e1-8893a0c47239\",\"sessionRestorable\":true}}"} {"ts":1787279795705,"run":1787279794200,"seq":10.363636363636363,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/delta\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"deltas\":[{\"kind\":\"turn.open\"},{\"kind\":\"item.textClose\",\"key\":{\"channel\":\"assistant\"},\"channel\":\"agentMessage\",\"text\":\"Not logged in · Please run /login\"}]}}"} {"ts":1787279795706,"run":1787279794200,"seq":10.454545454545455,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"provider/recovery\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"kind\":\"authRequired\",\"message\":\"Not logged in · Please run /login\",\"retryable\":false}}"} -{"ts":1787279795707,"run":1787279794200,"seq":10.545454545454545,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/delta\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"deltas\":[{\"kind\":\"turn.open\"},{\"kind\":\"contextWindow\",\"used\":0,\"size\":1000000,\"estimated\":true,\"attach\":\"open\"},{\"kind\":\"usage\",\"total\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"last\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"modelContextWindow\":null},{\"kind\":\"provider.error\",\"message\":\"Provider error\",\"detail\":\"Not logged in · Please run /login\"},{\"kind\":\"turn.boundary\",\"status\":\"failed\",\"providerCheckpointId\":\"4a270486-b600-43dd-a6bd-07b72524f931\"}]}}"} +{"ts":1787279795707,"run":1787279794200,"seq":10.545454545454545,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/delta\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"deltas\":[{\"kind\":\"turn.open\"},{\"kind\":\"contextWindow\",\"used\":0,\"size\":1000000,\"estimated\":true,\"attach\":\"open\"},{\"kind\":\"usage\",\"total\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"last\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"modelContextWindow\":null},{\"kind\":\"provider.error\",\"message\":\"Provider error\",\"detail\":\"Not logged in · Please run /login\",\"errorInfo\":{\"category\":\"unauthorized\",\"providerCode\":\"authentication_failed\",\"httpStatusCode\":null}},{\"kind\":\"turn.boundary\",\"status\":\"failed\",\"providerCheckpointId\":\"4a270486-b600-43dd-a6bd-07b72524f931\"}]}}"}