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/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/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/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/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/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\"}]}}"} 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([ 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",