Classify Claude auth failures and offer sign-in from the thread - #3338
Open
t1mdurden wants to merge 3 commits into
Open
Classify Claude auth failures and offer sign-in from the thread#3338t1mdurden wants to merge 3 commits into
t1mdurden wants to merge 3 commits into
Conversation
added 3 commits
September 9, 2026 16:47
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.
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.
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.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Human comments
What was wrong
When a Claude Code OAuth session expires mid-thread, the CLI reports the reason on the assistant message (
error: "authentication_failed"), but bb builds the turn'sProviderErrorInfofrom the result message alone (delta-translation.ts, thebuildClaudeProviderErrorInfo({ resultSubtype })call).subtype: "error_during_execution"maps to category"unknown", anderror-display.tsonly uses its titles table when the category is not"unknown"— so the existing string"Provider authorization failed"never fires for the most common authorization failure. The user gets the CLI's apology sentence as an untitled error row, the composer stays enabled, and every further message fails the same way. Nothing in the app readsProviderHealth.loginCommand("claude /login"), so there is no way to re-authenticate without leaving bb.Fixes #3337
What changed
Classification (the bug).
plugins/provider-claude-code: the assistant message'serrorcode is now armed on the translator's per-turn state and consumed when the turn's terminalprovider.erroris built, exactly asarmedHardRateLimitRejectionalready does for a deferred hard rate-limit rejection. It is segment-scoped and cleared on every turn open, turn close and result, so it can never leak into a later turn.authentication_failedtherefore reachesProviderErrorInfoascategory: "unauthorized",providerCode: "authentication_failed", and the row title becomes "Provider authorization failed". A new narrow schema (claudeAssistantErrorMessageSchema) parses the code at the boundary; an unrecognised code simply does not arm, matching today's behavior.Signal (thread-scoped, event-driven).
packages/thread-viewgainsextractThreadTimelineProviderAuthRequired, built the same way as the existing model-fallback extraction: it reports the sequence of the latest terminalunauthorizedprovider error and clears on the nextclient/turn/requested. It rides the latest timeline page asproviderAuthRequired, alongsidemodelFallback— a new nullable field onthreadTimelineResponseSchema. No host-daemon or provider-bridge wire field changes, so noHOST_DAEMON_PROTOCOL_VERSIONbump.Affordance (the button).
ProviderAuthBanneris a new prompt-stack card, a sibling ofProviderCliVersionBanner, rendered inThreadDetailPromptAreanext toThreadModelFallbackCard. It appears only when the thread's last turn failed withunauthorized. Its data comes from the provider's own health report —useSystemProviderStatesis enabled only while the banner is showing, so threads that are fine pay nothing. The button calls the thread's existing terminal path:handleStartTerminalnow takes an optionalCreateTerminalRequest["start"]and is invoked with{ mode: "command", command: loginCommand }, so the user gets a real PTY already running the provider's own login command, in the thread's own environment, and finishes the browser flow there. The interactive part matters:provider.installation.run— the pipe the install/update button uses — has no stdin, so an OAuth code could never be pasted through it.Because the command string comes from the provider plugin, Cursor's
"cursor-agent login"works with no app-side changes. When no terminal can be opened (host disconnected, no environment) the banner drops the button and prints the command instead, rather than offering an action that cannot run.No new CLI or SDK surface: the signal rides the existing
threads.timelineresponse, and the action isbb terminal create --thread <id> --command "<loginCommand>", which already exists.How you verified
Tests added; each fails before the change and passes after.
plugins/provider-claude-code/src/delta-translation.test.ts— "classifies an expired Claude OAuth session as unauthorized" (fails before: category"unknown"), plus a guard that an assistant error code does not carry into a later turn.packages/thread-view/test/provider-auth-extraction.test.ts— 5 cases: reports, ignores other categories, ignoreswillRetry, clears on the next turn request, reports again on a repeat failure.apps/app/src/components/promptbox/banner/ProviderAuthBanner.test.tsx— 3 cases: action fires, no-terminal fallback copy, disabled while opening.apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx— banner renders and fires above the composer; absent when authorized.Commands run:
One
@bb/servertest,builtin-plugins.test.ts > hot-reloads a source-layout builtin server…, failed once under parallel load and passes on its own; it is a timing test unrelated to these files.Not in this change
apps/host-daemon/src/runtime-manager.tsstill swallows everyprovider/recoveryhint in alogger.debugcall, so the bridge's ownauthRequiredclassification never reaches the server. Forwarding it is a host-daemon→server wire addition and needs its own change and protocol bump; it is written up in the issue. Until then one hole remains: a refresh token revoked whileexpiresAtis still in the future leavesprovider.healthreportingready, so the banner's button falls back to printing the login command rather than opening the terminal. The banner itself still appears, because it is driven by the turn failure rather than by health.