From 057c290b1dc089281e5b2087612a5cc2d31fdf95 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 2 Oct 2026 14:42:08 -0700 Subject: [PATCH 1/2] feat(providers): offer reconnect for auth-class inference failures --- src/inference-error-message.test.ts | 150 +++++++++ src/inference-error-message.ts | 56 +++- src/inference-gateway-error.test.ts | 126 +++++++ src/inference-gateway-error.ts | 152 ++++++++- src/tui/command-surfaces.ts | 26 +- src/tui/commands/built-in.test.ts | 77 +++++ src/tui/commands/built-in.ts | 18 +- src/tui/commands/registry.ts | 9 +- src/tui/connect-scope.ts | 32 ++ src/tui/product-host.ts | 59 +++- src/tui/provider/connect.ts | 5 + src/tui/provider/setup.ts | 3 + src/tui/provider/types.ts | 6 + src/tui/runner/commands.ts | 7 +- src/tui/runner/exit.test.ts | 137 ++++++++ src/tui/runner/exit.ts | 43 ++- src/tui/runner/host.ts | 45 ++- src/tui/runner/index.ts | 13 + src/tui/runner/reconnect-recovery.test.ts | 389 ++++++++++++++++++++++ src/tui/runner/reconnect-recovery.ts | 281 ++++++++++++++++ src/tui/runner/settings.ts | 17 +- src/tui/runner/state.ts | 13 + src/tui/runner/submit.test.ts | 201 +++++++++++ src/tui/runner/submit.ts | 42 ++- src/tui/stream-event-map.test.ts | 2 +- 25 files changed, 1873 insertions(+), 36 deletions(-) create mode 100644 src/tui/connect-scope.ts create mode 100644 src/tui/runner/reconnect-recovery.test.ts create mode 100644 src/tui/runner/reconnect-recovery.ts create mode 100644 src/tui/runner/submit.test.ts diff --git a/src/inference-error-message.test.ts b/src/inference-error-message.test.ts index 633626365..ca0071553 100644 --- a/src/inference-error-message.test.ts +++ b/src/inference-error-message.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { normalizeInferenceErrorForTerminal } from "./inference-gateway-error.js"; import { + CREDENTIAL_FAILURE_USER_MESSAGE, inferenceErrorMessage, terminalProviderFailureMessage, } from "./inference-error-message.js"; @@ -200,6 +201,155 @@ describe("terminalProviderFailureMessage", () => { expect(message.toLowerCase()).not.toContain("retrying"); }); + test("terminal xAI OAuth 426 names the profile and spells the reconnect command", () => { + const normalized = normalizeInferenceErrorForTerminal( + { + category: "fatal", + message: "Upgrade Required", + statusCode: 426, + raw: { error: { code: "upgrade_required" } }, + }, + "xai/default-2", + ); + const message = terminalProviderFailureMessage("xai/default-2", normalized); + expect(message).toContain('xAI profile "default-2"'); + expect(message).toContain('"/connect xai default-2"'); + expect(message).toContain('reconnect profile "default-2"'); + }); + + test("terminal xAI 401 credential failure spells the reconnect command", () => { + const message = terminalProviderFailureMessage("xai/default-2", { + category: "credential_failure", + message: '{"error":{"code":401}}', + statusCode: 401, + providerId: "xai/default-2", + }); + expect(message).toContain("/connect"); + expect(message).toContain('"/connect xai default-2"'); + expect(message).toContain('reconnect profile "default-2"'); + }); + + test("terminal Codex credential failure keeps branded wording plus the explicit command", () => { + const normalized = normalizeInferenceErrorForTerminal( + { + category: "fatal", + message: "Not Found", + statusCode: 404, + raw: { + error: { + code: "invalid_token", + message: "Not authorized: the access token has been revoked", + }, + }, + }, + "codex/work", + ); + const message = terminalProviderFailureMessage("codex/work", normalized); + expect(message).toContain('Codex profile "work"'); + // Branded line keeps its wording (bare /connect); the explicit command is + // additive, never a stutter of the generic line. + expect(message).toContain("/connect"); + expect(message).toContain('"/connect codex work"'); + expect(message).not.toContain("run /connect to reconnect"); + }); + + test.each([ + { + name: "bare 404", + providerId: "codex/work", + error: { + category: "fatal" as const, + message: "Not Found", + statusCode: 404, + }, + exact: + 'codex/work Provider failed (fatal): Not Found. Try again or switch models with "/model".', + }, + { + name: "quota", + providerId: "xai/alice", + error: { + category: "quota_exhausted" as const, + message: "Too Many Requests", + statusCode: 429, + }, + exact: + "xai/alice Provider failed (retryable): Rate limited. Wait a moment and try again.", + }, + { + name: "overflow", + providerId: "xai/alice", + error: { + category: "context_overflow" as const, + message: "context length exceeded", + }, + exact: + "xai/alice Provider failed (context_overflow): context length exceeded. Try /clear to start fresh.", + }, + { + name: "non-OAuth 426", + providerId: "custom-provider", + error: { + category: "fatal" as const, + message: "Upgrade Required", + statusCode: 426, + }, + exact: + 'custom-provider Provider failed (fatal): Upgrade Required. Try again or switch models with "/model".', + }, + ])( + "non-reconnect $name keeps byte-identical guidance", + ({ providerId, error, exact }) => { + const normalized = normalizeInferenceErrorForTerminal(error, providerId); + const message = terminalProviderFailureMessage(providerId, normalized); + expect(message).toBe(exact); + expect(message).not.toContain('"/connect '); + }, + ); + + test.each([ + { + providerId: "xai/alice", + command: '"/connect xai alice"', + profile: "alice", + }, + { + providerId: "codex/alice", + command: '"/connect codex alice"', + profile: "alice", + }, + ])( + "reconnect-class $providerId spells the explicit reconnect command", + ({ providerId, command, profile }) => { + const normalized = normalizeInferenceErrorForTerminal( + { + category: "credential_failure", + message: "Unauthorized", + statusCode: 401, + }, + providerId, + ); + const message = terminalProviderFailureMessage(providerId, normalized); + expect(message).toContain( + `Run ${command} to reconnect profile "${profile}".`, + ); + }, + ); + + test("bare kind-only id is not reconnect-class and keeps base guidance", () => { + const normalized = normalizeInferenceErrorForTerminal( + { + category: "credential_failure", + message: "Unauthorized", + statusCode: 401, + }, + "xai", + ); + const message = terminalProviderFailureMessage("xai", normalized); + expect(message).not.toContain('"/connect '); + expect(message).toContain(CREDENTIAL_FAILURE_USER_MESSAGE); + }); + test("retryable 429 guidance asks the operator to wait before trying again", () => { const message = terminalProviderFailureMessage("codex/default", { category: "retryable", diff --git a/src/inference-error-message.ts b/src/inference-error-message.ts index 152605a73..60a88763a 100644 --- a/src/inference-error-message.ts +++ b/src/inference-error-message.ts @@ -18,6 +18,7 @@ import { gatewayOverloadUserMessage, isCodexShortRateLimitInferenceError, isGatewayOverloadInferenceError, + isKnownOAuthProviderId, isXaiShortRateLimitInferenceError, parseCodexUsageLimitFromError, RATE_LIMIT_USER_MESSAGE, @@ -167,22 +168,71 @@ export function terminalProviderFailureMessage( const diagnosticSentence = /[.!?]$/.test(diagnostic) ? diagnostic : `${diagnostic}.`; - const guidance = terminalProviderFailureGuidance(error, category); + const guidance = terminalProviderFailureGuidance(error, category, providerId); const tail = guidance.length > 0 ? ` ${guidance}` : ""; return `${label} Provider failed (${category}): ${diagnosticSentence}${tail}`; } +/** + * Split a `kind/name` provider id into its reconnect scope. Unslashed ids + * reconnect the `default` profile. Returns undefined for malformed ids so the + * guidance never spells a broken command. Shared with the reconnect + * descriptor (Phase 3) — one split, not two. + */ +export function splitReconnectScope( + providerId: string, +): { kind: string; profile: string } | undefined { + const slash = providerId.indexOf("/"); + if (slash <= 0) { + return providerId.length > 0 + ? { kind: providerId, profile: "default" } + : undefined; + } + const kind = providerId.slice(0, slash); + const profile = providerId.slice(slash + 1); + if (kind.length === 0 || profile.length === 0) return undefined; + return { kind, profile }; +} + +/** + * Explicit one-action reconnect command for reconnect-class terminal failures + * (credential_failure on a known-OAuth id): `Run "/connect " + * to reconnect profile "".` Empty for anything else, and empty when + * the diagnostic already carries the explicit command — never a stutter. + */ +function reconnectCommandGuidance( + providerId: string, + diagnosticMessage: string, +): string { + if (!isKnownOAuthProviderId(providerId)) return ""; + const scope = splitReconnectScope(providerId); + if (scope === undefined) return ""; + const command = `"/connect ${scope.kind} ${scope.profile}"`; + if (diagnosticMessage.includes(command)) return ""; + return `Run ${command} to reconnect profile "${scope.profile}".`; +} + function terminalProviderFailureGuidance( error: InferenceErrorLike, category: string, + providerId: string, ): string { if (category === "credential_failure") { // Normalized credential failures already carry the re-login hint in the // diagnostic (e.g. Codex profile copy); repeating it reads as a stutter. // Shared with the classifier via carriesCodexReLoginHint — one predicate. - return carriesCodexReLoginHint(error.message ?? "") + const base = carriesCodexReLoginHint(error.message ?? "") ? "" : CREDENTIAL_FAILURE_USER_MESSAGE; + // Reconnect-class failures additionally spell the explicit command so the + // operator can repair the named profile in one action (issue #1295). The + // Codex branded line keeps its wording; the explicit command is additive. + const explicit = reconnectCommandGuidance( + error.providerId ?? providerId, + error.message ?? "", + ); + if (explicit.length === 0) return base; + return base.length > 0 ? `${base} ${explicit}` : explicit; } if (category === "context_overflow") return "Try /clear to start fresh."; // A 429 that survived the harness's paced retries is a wait-it-out rate @@ -210,7 +260,7 @@ function terminalProviderFailureSummary( ): string { const label = terminalProviderFailureLabel(providerId, displayLabel); const category = terminalProviderFailureCategory(error); - const guidance = terminalProviderFailureGuidance(error, category); + const guidance = terminalProviderFailureGuidance(error, category, providerId); const tail = guidance.length > 0 ? ` ${guidance}` : ""; return `${label} Provider failed (${category}).${tail}`; } diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 8fe186bd2..8d800740a 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -12,6 +12,18 @@ const CLOUDFLARE_503_HTML = `

503 Service Temporarily Unavailable

Cloudflare Ray ID: abc

`; +// PROVISIONAL (Phase 0, issue #1295): the real xAI 426 body for xai/default-2 +// is unknown — the issue reports a bare "HTTP 426 Upgrade Required". Grounded +// in the status code, the reason phrase, and the OAuth provider id only; no +// body-signal assertions until a real payload lands. +const PROVISIONAL_XAI_426_UPGRADE_REQUIRED = { + category: "fatal" as const, + message: "Upgrade Required", + statusCode: 426, + providerId: "xai/default-2", + raw: { error: { code: "upgrade_required", message: "Upgrade Required" } }, +}; + describe("looksLikeHtmlGatewayBody", () => { test("detects doctype HTML", () => { expect(looksLikeHtmlGatewayBody(CLOUDFLARE_503_HTML)).toBe(true); @@ -73,6 +85,120 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalizeInferenceErrorForRetry(err)).toEqual(err); }); + // Phase 1 (issue #1295): normalizeOAuthUpgradeRequiredError grounds the + // provisional marker list — OAuth 426 with upgrade signal is reconnect-class. + test("PROVISIONAL: xAI OAuth 426 with upgrade signal normalizes to credential_failure", () => { + const normalized = normalizeInferenceErrorForRetry( + PROVISIONAL_XAI_426_UPGRADE_REQUIRED, + ); + expect(normalized.category).toBe("credential_failure"); + expect(normalized.statusCode).toBe(426); + expect(normalized.message).toContain('xAI profile "default-2"'); + expect(normalized.message).toContain("/connect"); + expect(normalized.raw).toEqual(PROVISIONAL_XAI_426_UPGRADE_REQUIRED.raw); + }); + + test("xAI OAuth 426 with raw-body auth signal names the profile", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "", + statusCode: 426, + providerId: "xai/default-2", + raw: { error: { message: "Not authorized: token revoked" } }, + }); + expect(normalized.category).toBe("credential_failure"); + expect(normalized.message).toContain('xAI profile "default-2"'); + }); + + test("Codex OAuth 426 with auth signal reuses the branded re-login line", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Upgrade Required", + statusCode: 426, + providerId: "codex/work", + raw: { error: { code: "invalid_token" } }, + }); + expect(normalized.category).toBe("credential_failure"); + expect(normalized.message).toContain('Codex profile "work"'); + expect(carriesCodexReLoginHint(normalized.message)).toBe(true); + }); + + test("OAuth 426 without body signal is still reconnect-class on an OAuth id", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "", + statusCode: 426, + providerId: "xai/default-2", + }); + expect(normalized.category).toBe("credential_failure"); + expect(normalized.statusCode).toBe(426); + expect(normalized.message).toContain('xAI profile "default-2"'); + }); + + test.each([ + { + name: "API-key 426", + error: { + category: "fatal" as const, + message: "Upgrade Required", + statusCode: 426, + providerId: "openai/prod", + }, + }, + { + name: "custom-provider 426", + error: { + category: "fatal" as const, + message: "Upgrade Required", + statusCode: 426, + providerId: "custom-provider", + }, + }, + { + name: "provider-less 426", + error: { + category: "fatal" as const, + message: "Upgrade Required", + statusCode: 426, + }, + }, + { + name: "bare OAuth 426 without signal on a non-OAuth id", + error: { + category: "fatal" as const, + message: "", + statusCode: 426, + providerId: "openai/prod", + }, + }, + ])("non-OAuth $name stays fatal", ({ error }) => { + expect(normalizeInferenceErrorForRetry(error)).toEqual(error); + }); + + test("OAuth 426 with quota text stays fatal", () => { + const err = { + category: "fatal" as const, + message: "Upgrade Required", + statusCode: 426, + providerId: "xai/default-2", + raw: { error: { message: "exceeded your current quota" } }, + }; + expect(normalizeInferenceErrorForRetry(err)).toEqual(err); + }); + + test("OAuth 426 with deprecation text stays fatal", () => { + const err = { + category: "fatal" as const, + message: "Upgrade Required", + statusCode: 426, + providerId: "xai/default-2", + raw: { + error: { message: "model 'grok-x' has expired — migrate to 'grok-y'" }, + }, + }; + expect(normalizeInferenceErrorForRetry(err)).toEqual(err); + }); + test("maps GoUsageLimitError 429 to quota_exhausted", () => { const normalized = normalizeInferenceErrorForRetry({ category: "retryable", diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index d0bafffb5..8701332bd 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -14,7 +14,10 @@ import { codexProfileFromProviderName, isCodexProviderName, } from "./config/codex-providers.js"; -import { isXaiProviderName } from "./config/xai-providers.js"; +import { + isXaiProviderName, + xaiProfileFromProviderName, +} from "./config/xai-providers.js"; import { isXaiGrokLeafProvider } from "./subagent/provider-family.js"; export interface InferenceErrorLike { @@ -666,6 +669,145 @@ function normalizeCodexFatal400Message( }; } +/** + * OAuth provider ids eligible for upgrade-class reconnect (issue #1295): + * known-xAI / Grok-leaf and known-Codex ids. Shared with the transcript + * guidance and the reconnect descriptor so all three agree on the set. + */ +export function isKnownOAuthProviderId( + providerId: string | undefined, +): providerId is string { + if (providerId === undefined || providerId.length === 0) return false; + if (isCodexProviderName(providerId)) return true; + return isKnownXaiProviderId(providerId); +} + +/** + * Upgrade/auth-rejection signals for the OAuth 426 classifier: the 426 + * "Upgrade Required" reason phrase plus the auth-rejection phrasing shared + * with the Codex credential-404 markers. On an OAuth profile a 426 means the + * request reached the provider unauthenticated (CL-6973), so recognized + * phrasing marks it reconnect-class. + */ +const OAUTH_UPGRADE_426_MARKERS = [ + "upgrade", + "unauthenticated", + "unauthorized", + "unauthorised", + "invalid token", + "invalid_token", + "expired", + "revoked", + "reconnect", + "reauthenticate", + "re-authenticate", +] as const; + +function hasOAuthUpgrade426Signal(error: InferenceErrorLike): boolean { + return combinedTextIncludesMarker( + [error.message ?? "", stringFromRaw(error.raw)], + OAUTH_UPGRADE_426_MARKERS, + ); +} + +/** + * Deprecation phrasing that vetoes the 426 classifier. A retired-model or + * sunset-API 426 is a fatal migrate-models failure, never a credential + * failure — same rationale as the Codex model-deprecation veto above. + */ +const OAUTH_UPGRADE_426_DEPRECATION_MARKERS = [ + "deprecated", + "deprecation", + "retired", + "sunset", + "end of life", + "no longer supported", + "no longer available", + "migrate", +] as const; + +function looksLikeOAuthUpgrade426Deprecation( + error: InferenceErrorLike, +): boolean { + return combinedTextIncludesMarker( + [error.message ?? "", stringFromRaw(error.raw)], + OAUTH_UPGRADE_426_DEPRECATION_MARKERS, + ); +} + +/** + * Branded re-auth line for a non-Codex OAuth 426, mirroring + * formatCodexCredential404Message: profile named, bare /connect spelled, the + * server diagnostic in parens when recognized. An empty diagnostic leaves the + * branded line standing alone. + */ +function formatOAuthUpgrade426Message( + kindLabel: string, + chooseClause: string, + profile: string, + originalDiagnostic: string, +): string { + const branded = `${kindLabel} profile "${profile}" needs re-authentication. Run /connect${chooseClause} and reconnect profile "${profile}".`; + const oneLine = originalDiagnostic.replace(/\s+/g, " ").trim(); + if (oneLine.length === 0 || branded.includes(oneLine)) return branded; + const clipped = oneLine.length > 200 ? `${oneLine.slice(0, 199)}…` : oneLine; + return `${branded} (${clipped})`; +} + +/** + * OAuth 426 (Upgrade Required) is an unauthenticated-provider rejection + * (CL-6973), not a bad model name: a fatal 426 on a known-OAuth id becomes + * credential_failure so the transcript can offer a reconnect. Quota markers + * veto (a quota-flavored 426 stays on its existing path), as does + * deprecation phrasing. There is deliberately no body-signal requirement: the + * reported xAI 426 arrives bare (issue #1295), so status-426 on an OAuth id + * is sufficient — non-OAuth 426s never reach here. Recognized upgrade-class + * phrasing is echoed in the branded line; unrecognized server copy stays on + * `raw` for logs instead of the action line. + */ +function normalizeOAuthUpgradeRequiredError( + error: InferenceErrorWithGoContext, +): InferenceError { + if (error.category !== "fatal") return error; + if (error.statusCode !== 426) return error; + const providerId = error.providerId; + if (!isKnownOAuthProviderId(providerId)) return error; + if (looksLikeOAuthUpgrade426Deprecation(error)) return error; + if (textHasXaiQuotaMarkers(error.message ?? "", stringFromRaw(error.raw))) { + return error; + } + const recognized = hasOAuthUpgrade426Signal(error); + const diagnostic = recognized ? (error.message ?? "") : ""; + const profile = + codexProfileFromProviderName(providerId) ?? + xaiProfileFromProviderName(providerId) ?? + (providerId.split("/").slice(1).join("/") || providerId); + const message = isCodexProviderName(providerId) + ? formatCodexCredential404Message(profile, diagnostic) + : providerId.split("/")[0] === "xai" + ? formatOAuthUpgrade426Message( + "xAI", + ", choose xAI,", + profile, + diagnostic, + ) + : formatOAuthUpgrade426Message( + providerId.split("/")[0] ?? providerId, + "", + profile, + diagnostic, + ); + return { + category: "credential_failure", + message, + statusCode: 426, + ...(error.raw !== undefined ? { raw: error.raw } : {}), + ...(error.retryAfterMs !== undefined + ? { retryAfterMs: error.retryAfterMs } + : {}), + }; +} + /** * Reclassify gateway overload errors so the default retry policy treats them as * transient instead of aborting on protocol_mismatch. Also normalizes OpenCode @@ -673,8 +815,9 @@ function normalizeCodexFatal400Message( * 429s, attributable xAI capacity protocol_mismatch, Codex usage limits * (nested detail.error with resets_in_seconds), known-Codex short 429s that * are not usage_limit_reached, known-Codex 404s carrying an - * auth-rejection signal (expired/revoked credential), and known-Codex fatal - * 400s whose message is empty or "Bad Request" (nested diagnostic on raw). + * auth-rejection signal (expired/revoked credential), known-Codex fatal + * 400s whose message is empty or "Bad Request" (nested diagnostic on raw), and + * known-OAuth fatal 426s (unauthenticated-provider upgrade rejection). */ export function normalizeInferenceErrorForRetry( error: InferenceErrorWithGoContext, @@ -700,6 +843,9 @@ export function normalizeInferenceErrorForRetry( const codexFatal400 = normalizeCodexFatal400Message(error); if (codexFatal400 !== error) return codexFatal400; + const oauthUpgrade = normalizeOAuthUpgradeRequiredError(error); + if (oauthUpgrade !== error) return oauthUpgrade; + if (!isGatewayOverloadInferenceError(error)) return error; if (error.category === "retryable" || error.category === "timeout") return error; diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 3248ea88a..2fae73882 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -17,6 +17,7 @@ import { isOwnedDiskInstall, } from "../plugins/uninstall.js"; import { maskEcho, maskSecret } from "./provider/form.js"; +import type { ReconnectScope } from "./connect-scope.js"; import { writeClipboard } from "./copy-path.js"; import { residualIdFromSelection, @@ -234,8 +235,16 @@ export interface CommandSurfaceDeps { readonly settings?: SettingsSurfaceDeps; /** Opens the host's model/provider picker (owned by the product host). */ readonly openModels?: () => void; - /** Opens the host's add-provider selector (owned by the product host). `/connect` omits returnToModels. */ - readonly openAddProvider?: (opts?: { returnToModels?: boolean }) => void; + /** + * Opens the host's add-provider selector (owned by the product host). + * `/connect` omits returnToModels. `/connect [profile]` pre-scopes + * via initialKind/initialProfile (kind row focused, profile to the flow). + */ + readonly openAddProvider?: (opts?: { + returnToModels?: boolean; + initialKind?: string; + initialProfile?: string; + }) => void; /** Fallback channel for surfaces with no live data source. */ readonly notify: (text: string) => void; } @@ -1649,12 +1658,14 @@ function errorText(err: unknown): string { /** * Open the surface a command asked for. * Returns false when no surface exists for the kind, so the caller can report - * the gap rather than silently swallowing the command. + * the gap rather than silently swallowing the command. `connectScope` + * pre-scopes the add-provider selector to one kind/profile for reconnects. */ export function openCommandSurface( shell: AppShell, kind: CommandSurfaceKind, deps: CommandSurfaceDeps, + connectScope?: ReconnectScope, ): boolean { switch (kind) { case "help": @@ -1681,7 +1692,14 @@ export function openCommandSurface( return true; case "add-provider": if (deps.openAddProvider === undefined) return false; - deps.openAddProvider(); + deps.openAddProvider( + connectScope !== undefined + ? { + initialKind: connectScope.kind, + initialProfile: connectScope.profile, + } + : undefined, + ); return true; } } diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 21eb6234e..0525c3fd4 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -67,6 +67,83 @@ describe("/connect command", () => { }); }); + it("returns the scope for /connect [profile]", () => { + expect( + defined(getCommand("connect"), "connect").handler( + "xai default-2", + makeCtx(), + ), + ).toEqual({ + type: "overlay", + overlay: "add-provider", + connectScope: { kind: "xai", profile: "default-2" }, + }); + expect( + defined(getCommand("connect"), "connect").handler("xai", makeCtx()), + ).toEqual({ + type: "overlay", + overlay: "add-provider", + connectScope: { kind: "xai", profile: "default" }, + }); + }); + + it("dispatch forwards the pre-scope to the add-provider surface", () => { + const calls: { kind: string; scope: unknown }[] = []; + const state = { + host: { + openSurface: (kind: string, scope?: unknown) => { + calls.push({ kind, scope }); + return true; + }, + }, + } as unknown as RunnerState; + createCommandLayer(state, {} as unknown as RunnerServices); + defined(state.dispatchCommand, "dispatchCommand")( + "connect", + "xai default-2", + ); + expect(calls).toEqual([ + { kind: "add-provider", scope: { kind: "xai", profile: "default-2" } }, + ]); + }); + + it("dispatch opens the add-provider surface unscoped for bare /connect", () => { + const calls: { kind: string; scope: unknown }[] = []; + const state = { + host: { + openSurface: (kind: string, scope?: unknown) => { + calls.push({ kind, scope }); + return true; + }, + }, + } as unknown as RunnerState; + createCommandLayer(state, {} as unknown as RunnerServices); + defined(state.dispatchCommand, "dispatchCommand")("connect", ""); + expect(calls).toEqual([{ kind: "add-provider", scope: undefined }]); + }); + + it("dispatch reports usage for invalid scope args without opening a surface", () => { + const notices: string[] = []; + const state = { + systemNotice: (text: string) => { + notices.push(text); + }, + host: { + openSurface: (): boolean => { + throw new Error("must not open a surface for invalid args"); + }, + }, + } as unknown as RunnerState; + createCommandLayer(state, {} as unknown as RunnerServices); + defined(state.dispatchCommand, "dispatchCommand")( + "connect", + "too many parts here", + ); + expect(notices).toEqual([ + 'Usage: /connect [profile] — e.g. "/connect xai default-2".', + ]); + }); + it("is discoverable by auth recovery terms", () => { const catalog = commandItemsFromRegistry(listCommands()); for (const query of ["auth", "login", "reauth", "credential"]) { diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 9ae3cf0cc..461ed6af6 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -1,4 +1,5 @@ import { registerCommand } from "./registry.js"; +import { parseConnectScopeArgs } from "../connect-scope.js"; import { formatCostCommandOutput } from "../../cost/cost-summary.js"; import { formatStartupChangelog, @@ -57,11 +58,26 @@ export function registerBuiltInCommands(): void { // Layout-proof add-provider path: `/` works on every keyboard. There is no // standalone /login; OAuth sign-in is still reached only through this flow. + // `/connect [profile]` pre-scopes the overlay to one account so a + // reconnect offer (or a pasted terminal command) lands on the failed row. registerCommand({ name: "connect", description: "Connect or reauthenticate a provider account (auth, login, credentials)", - handler: () => ({ type: "overlay", overlay: "add-provider" }), + argumentHint: "[ [profile]]", + handler: (args) => { + if (args.trim().length === 0) { + return { type: "overlay", overlay: "add-provider" }; + } + const scope = parseConnectScopeArgs(args); + if (scope === undefined) { + return { + type: "message", + text: 'Usage: /connect [profile] — e.g. "/connect xai default-2".', + }; + } + return { type: "overlay", overlay: "add-provider", connectScope: scope }; + }, }); // signalClear rotates to a fresh session: the on-screen transcript and run diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 67bb128e2..4456ae89e 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -1,5 +1,6 @@ import type { CostSummary } from "../../cost/cost-summary.js"; import type { PluginOrigin } from "../../trust/project-trust.js"; +import type { ReconnectScope } from "../connect-scope.js"; export interface CommandContext { signalClear: () => void; @@ -55,8 +56,12 @@ export type CommandResult = | "plugins" | "settings" | "hooks" - | "mcp" - | "add-provider"; + | "mcp"; + } + | { + type: "overlay"; + overlay: "add-provider"; + connectScope?: ReconnectScope; } | { type: "modal"; modal: "agent" | "codex-login" | "xai-login" } | { type: "workflow"; name: string; args?: string } diff --git a/src/tui/connect-scope.ts b/src/tui/connect-scope.ts new file mode 100644 index 000000000..46a867578 --- /dev/null +++ b/src/tui/connect-scope.ts @@ -0,0 +1,32 @@ +/** + * Neutral leaf for the `/connect [profile]` scope both the slash + * command and the reconnect-recovery state share. Lives outside commands/ + * and runner/ so neither layer reaches into the other for one split. + */ + +export interface ReconnectScope { + readonly kind: string; + readonly profile: string; +} + +/** Parse `/connect [profile]` args; bare kind reconnects `default`. */ +export function parseConnectScopeArgs( + rawArgs: string, +): ReconnectScope | undefined { + const parts = rawArgs + .trim() + .split(/\s+/) + .filter((part) => part.length > 0); + if (parts.length === 0 || parts.length > 2) return undefined; + const kind = parts[0] ?? ""; + const profile = parts.length === 2 ? (parts[1] ?? "") : "default"; + if (kind.length === 0 || profile.length === 0) return undefined; + if (!/^[a-z0-9_-]+$/i.test(kind)) return undefined; + if (!/^[a-z0-9_-]+$/i.test(profile)) return undefined; + return { kind, profile }; +} + +/** Build the descriptor the terminal copy + affordance share: one spelling. */ +export function buildReconnectCommand(scope: ReconnectScope): string { + return `/connect ${scope.kind} ${scope.profile}`; +} diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 927bc0d05..6aeaf998c 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -121,6 +121,17 @@ export interface ProductHostAddProviderChoice { readonly accountCount: number; } +/** + * Pre-scoped reconnect context for a provider connect (`/connect + * [profile]` args, or the idle one-action reconnect offer). `profile` is the + * existing slug being re-keyed — the connect flow prefills it, never skips + * the confirm-to-re-key. + */ +export interface ProductHostConnectRequest { + readonly kind?: string; + readonly profile?: string; +} + export interface ProductHostConfig { readonly title: string; /** Working directory carried by the prompt box's bottom border. */ @@ -149,9 +160,15 @@ export interface ProductHostConfig { /** * Picking a provider in the Alt+A add-provider selector calls this. Caller * runs the connect flow and, on success, updates `models`/`describeModel` - * via `setModels` and reopens the picker. + * via `setModels` and reopens the picker. `req.profile` pre-scopes a + * reconnect (`/connect `): the connect flow prefills the + * account-name step with the existing slug; the confirm-to-re-key runs + * unchanged. */ - readonly onConnectProvider?: (providerName: string) => void; + readonly onConnectProvider?: ( + providerName: string, + req?: ProductHostConnectRequest, + ) => void; /** Alt+F on a focused model row. Bare `f` is claimed by type-to-filter. */ readonly onFavoriteToggle?: (itemId: string) => void; /** Alt+D on a focused model row. Bare `d` is claimed by type-to-filter. */ @@ -243,9 +260,15 @@ export interface ProductHost { * Opens the add-provider selector; absent when connect choices are not wired. * Pass `returnToModels: true` when opening from the model picker (Alt+A) so * Esc returns there. `/connect` and other closed-prompt callers omit it so - * Esc dismisses to a closed overlay. + * Esc dismisses to a closed overlay. `initialKind`/`initialProfile` pre-scope + * a reconnect (`/connect `): the kind row is focused and the + * profile rides to the connect flow via ProductHostConnectRequest. */ - readonly openAddProvider?: (opts?: { returnToModels?: boolean }) => void; + readonly openAddProvider?: (opts?: { + returnToModels?: boolean; + initialKind?: string; + initialProfile?: string; + }) => void; /** Swap the picker's rows/descriptions in place (e.g. after a provider connects). */ readonly setModels?: ( models: readonly ProductHostModelOption[], @@ -571,7 +594,11 @@ export async function mountProductHost( let currentDescribeModel = config.describeModel; let openModels: ((focusId?: string) => void) | undefined; let openAddProvider: - | ((opts?: { returnToModels?: boolean }) => void) + | ((opts?: { + returnToModels?: boolean; + initialKind?: string; + initialProfile?: string; + }) => void) | undefined; if (config.onModelSelect) { const onSelect = config.onModelSelect; @@ -589,17 +616,37 @@ export async function mountProductHost( // underneath it, which does not apply here. openAddProvider = addProviderChoices !== undefined && onConnect !== undefined - ? (opts?: { returnToModels?: boolean }): void => { + ? (opts?: { + returnToModels?: boolean; + initialKind?: string; + initialProfile?: string; + }): void => { const rows = addProviderChoices(); + const scopedIndex = + opts?.initialKind !== undefined + ? rows.findIndex((r) => r.id === opts.initialKind) + : -1; openAddProviderOverlay(shell, { items: rows.map( (r) => `${r.label} — ${r.accountCount} account${r.accountCount === 1 ? "" : "s"}`, ), itemIds: rows.map((r) => r.id), + ...(scopedIndex >= 0 ? { activeIndex: scopedIndex } : {}), onAccept: (sel) => { const id = sel.id; if (id === undefined || id.length === 0) return; + // A pre-scoped reconnect carries the profile to the connect + // flow (account-name prefill); anything else connects bare. + // Unknown kinds fall back to the full list — the overlay + // opened unscoped, so there is nothing stale to carry. + if ( + opts?.initialProfile !== undefined && + (opts.initialKind === undefined || opts.initialKind === id) + ) { + onConnect(id, { kind: id, profile: opts.initialProfile }); + return; + } onConnect(id); }, describe: (itemId) => { diff --git a/src/tui/provider/connect.ts b/src/tui/provider/connect.ts index dd02c5400..7574cabc9 100644 --- a/src/tui/provider/connect.ts +++ b/src/tui/provider/connect.ts @@ -16,6 +16,8 @@ import { export interface ConnectProviderInput { readonly providerId: string; + /** Prefill the OAuth account-name step with the profile slug being re-keyed. */ + readonly initialOAuthProfile?: string; readonly settingsPath: string; /** Project-local selection file, or null when it aliases global settings. */ readonly localSettingsPath: string | null; @@ -54,6 +56,9 @@ export async function connectProviderInline( const submitted = await runProviderSetup({ showTelemetryNotice: false, initialProviderId: input.providerId, + ...(input.initialOAuthProfile !== undefined + ? { initialOAuthProfile: input.initialOAuthProfile } + : {}), existingProviderNames: Object.keys(input.existing?.providers ?? {}), ...(input.createRenderer !== undefined ? { createRenderer: input.createRenderer } diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts index 14657975a..dbeabf2de 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -164,6 +164,9 @@ export async function runProviderSetup( state.values.name = preselected.label; state.values.baseURL = preselected.baseURL; state.values.model = preselected.defaultModel; + if (config.initialOAuthProfile !== undefined) { + state.values.oauthProfile = config.initialOAuthProfile; + } } } diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index 6f88c30a3..3b28008a1 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -156,6 +156,12 @@ export interface ProviderSetupConfig { * selector already knows which provider it wants. */ readonly initialProviderId?: string; + /** + * Prefill the OAuth account-name step with the existing profile slug being + * re-keyed (e.g. `/connect xai default-2`). The collision confirm still runs + * unchanged — prefill never skips the confirm-to-re-key. + */ + readonly initialOAuthProfile?: string; /** * Catalog keys already present in global settings. Used by the API-key * multi-instance name step for suggested slugs and collision confirms. diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts index 47acacf2e..96ee13b04 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -321,7 +321,12 @@ export function createCommandLayer( case "noop": return; case "overlay": - if (!hostOf(state).openSurface(result.overlay)) { + if ( + !hostOf(state).openSurface( + result.overlay, + result.overlay === "add-provider" ? result.connectScope : undefined, + ) + ) { const named = result.overlay === "add-provider" ? "connect" : result.overlay; state.systemNotice?.(`No surface for /${named}.`); diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index 2d2bcc21e..ab67c2361 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -36,6 +36,7 @@ import { closeAgentForRebuild, createRunLifecycle, finalizeTUIRun, + observeRecoveryAttempts, resetSessionForRotation, resyncIdleWithFleetFlag, startInterruptRebuild, @@ -45,6 +46,10 @@ import { createCompactionLifecycle, } from "../../session/compaction-lifecycle.js"; import type { RunnerServices, RunnerState } from "./state.js"; +import type { InboundMessage } from "@intx/types/runtime"; +import { createCredentialRecoveryState } from "./credential-recovery.js"; +import { createReconnectRecoveryState } from "./reconnect-recovery.js"; +import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; function stubQuit(args: { awaitTail: () => Promise; @@ -926,3 +931,135 @@ describe("rebuild close helpers", () => { expect(order.indexOf("enqueue")).toBeGreaterThan(0); }); }); + +describe("observeRecoveryAttempts fan-out", () => { + function operatorMessage(): InboundMessage { + return { + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: "user@local", + to: ["agent@local"], + date: "2026-09-26T00:00:00.000Z", + messageId: "", + interchangeType: "conversation.message", + }, + flags: ["operator-originated"], + signatureStatus: "missing" as const, + content: "inspect this", + }; + } + + function credentialRetry() { + return { + type: "inference.retry", + data: { + previousError: { category: "credential_failure", message: "401" }, + }, + }; + } + + function credentialFailure(providerId: string) { + return { + type: "inference.error", + data: { + error: { category: "credential_failure", message: "401", providerId }, + }, + }; + } + + function recoverySeat() { + const credentialRecovery = createCredentialRecoveryState(); + const reconnectRecovery = createReconnectRecoveryState(); + const credentialRecoveryAttempts = new WeakMap(); + const reconnectRecoveryAttempts = new WeakMap(); + return { + state: { + credentialRecovery, + credentialRecoveryAttempts, + reconnectRecovery, + reconnectRecoveryAttempts, + }, + credentialRecovery, + reconnectRecovery, + credentialRecoveryAttempts, + reconnectRecoveryAttempts, + }; + } + + test("the same sink event reaches both credential and reconnect attempts", () => { + const seat = recoverySeat(); + const providerAttempt = {} as ProviderFailureAttempt; + const credentialAttempt = seat.credentialRecovery.begin( + operatorMessage(), + "xai", + ); + const reconnectAttempt = seat.reconnectRecovery.begin( + operatorMessage(), + "xai", + ); + seat.credentialRecoveryAttempts.set(providerAttempt, credentialAttempt); + seat.reconnectRecoveryAttempts.set(providerAttempt, reconnectAttempt); + observeRecoveryAttempts(seat.state, providerAttempt, credentialRetry()); + observeRecoveryAttempts( + seat.state, + providerAttempt, + credentialFailure("xai/default-2"), + ); + expect( + seat.credentialRecovery.settle(credentialAttempt, [ + { + id: "openai/gpt-5", + label: "GPT-5 (openai)", + provider: "openai", + model: "gpt-5", + }, + ]), + ).not.toBeNull(); + expect(seat.reconnectRecovery.settle(reconnectAttempt)).not.toBeNull(); + }); + + test("no current provider attempt observes nothing on either state", () => { + const seat = recoverySeat(); + const credentialAttempt = seat.credentialRecovery.begin( + operatorMessage(), + "xai", + ); + const reconnectAttempt = seat.reconnectRecovery.begin( + operatorMessage(), + "xai", + ); + observeRecoveryAttempts(seat.state, undefined, credentialRetry()); + observeRecoveryAttempts( + seat.state, + undefined, + credentialFailure("xai/default-2"), + ); + expect(seat.credentialRecovery.settle(credentialAttempt, [])).toBeNull(); + expect(seat.reconnectRecovery.settle(reconnectAttempt)).toBeNull(); + }); + + test("an attempt tracked under another key sees only its own stream", () => { + const seat = recoverySeat(); + const tracked = {} as ProviderFailureAttempt; + const untracked = {} as ProviderFailureAttempt; + const reconnectAttempt = seat.reconnectRecovery.begin( + operatorMessage(), + "xai", + ); + seat.reconnectRecoveryAttempts.set(tracked, reconnectAttempt); + observeRecoveryAttempts(seat.state, untracked, credentialRetry()); + observeRecoveryAttempts( + seat.state, + untracked, + credentialFailure("xai/default-2"), + ); + expect(seat.reconnectRecovery.settle(reconnectAttempt)).toBeNull(); + observeRecoveryAttempts(seat.state, tracked, credentialRetry()); + observeRecoveryAttempts( + seat.state, + tracked, + credentialFailure("xai/default-2"), + ); + expect(seat.reconnectRecovery.settle(reconnectAttempt)).not.toBeNull(); + }); +}); diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 38dad6205..e57c89118 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -41,7 +41,10 @@ import { printResumeHint } from "../../session/resume-hint.js"; import { clearActiveDisposeHost } from "../../session/active-host.js"; import { syncRunStateHandle } from "../../session/active-run.js"; import { startRunHeartbeat } from "../../session/run-liveness.js"; -import { suppressProviderFailurePresentation } from "../provider/failure-attempt.js"; +import { + suppressProviderFailurePresentation, + type ProviderFailureAttempt, +} from "../provider/failure-attempt.js"; import { normalizeInferenceErrorForTerminal } from "../../inference-gateway-error.js"; import { ensureFreshInferenceSource } from "../../subagent/refresh-inference-source.js"; import { peekSourceCredentialSecret } from "../../config/source-credentials.js"; @@ -259,6 +262,35 @@ function createRunPersistence(state: RunnerState, services: RunnerServices) { return { writeRunSnapshot, persistRunSnapshot }; } +/** + * Fan one sink event out to whichever recovery attempts track the current + * provider-failure attempt. The credential picker and the reconnect offer + * share this submit/exit seat, so both observe the same retry/error stream + * and settle independently when the send ends. + */ +export function observeRecoveryAttempts( + state: Pick< + RunnerState, + | "credentialRecovery" + | "credentialRecoveryAttempts" + | "reconnectRecovery" + | "reconnectRecoveryAttempts" + >, + providerAttempt: ProviderFailureAttempt | undefined, + event: { readonly type: string; readonly data?: unknown }, +): void { + if (providerAttempt === undefined) return; + const credentialAttempt = + state.credentialRecoveryAttempts.get(providerAttempt); + if (credentialAttempt !== undefined) { + state.credentialRecovery.observe(credentialAttempt, event); + } + const reconnectAttempt = state.reconnectRecoveryAttempts.get(providerAttempt); + if (reconnectAttempt !== undefined) { + state.reconnectRecovery.observe(reconnectAttempt, event); + } +} + /** * Build the mutable run lifecycle over the assembled session: snapshot * persistence, the stream sink, the initial agent build, and the @@ -310,14 +342,7 @@ export async function createRunLifecycle( providerFailureAttempts.advanceToNextMessage(); } services.correlationAcceptance.observe(event); - const providerAttempt = providerFailureAttempts.current(); - const recoveryAttempt = - providerAttempt === undefined - ? undefined - : state.credentialRecoveryAttempts.get(providerAttempt); - if (recoveryAttempt !== undefined) { - state.credentialRecovery.observe(recoveryAttempt, event); - } + observeRecoveryAttempts(state, providerFailureAttempts.current(), event); if (event.type === "inference.start" || event.type === "inference.done") { providerFailureAttempts.reset(); } else if (event.type === "inference.error") { diff --git a/src/tui/runner/host.ts b/src/tui/runner/host.ts index 95a6c6567..cf95b42a4 100644 --- a/src/tui/runner/host.ts +++ b/src/tui/runner/host.ts @@ -47,6 +47,7 @@ import { mountProductHost, type ProductHost, type ProductHostAddProviderChoice, + type ProductHostConnectRequest, } from "../product-host.js"; import { onTurnBoundary } from "../../agent/reactor-events.js"; import type { CostSummary } from "../../cost/cost-summary.js"; @@ -61,6 +62,11 @@ import type { StreamRow } from "../stream.js"; import type { QueueKind } from "../delivery-queue.js"; import type { ShellOutputFeed } from "../../session/shell-output-feed.js"; import { openModelPickerOverlay } from "../overlays.js"; +import type { ReconnectScope } from "../connect-scope.js"; +import { + reconnectRecoveryItemId, + reconnectRecoveryItemLabel, +} from "./reconnect-recovery.js"; import type { CredentialRecoveryAlternative } from "./credential-recovery.js"; export interface RunnerHostDeps { @@ -97,7 +103,10 @@ export interface RunnerHostDeps { readonly activeModel?: () => ModelCatalogRef | undefined; readonly onModelSelect: (id: string) => void; /** Picking a row in the Alt+A add-provider selector; runner owns the connect flow. */ - readonly onConnectProvider?: (providerName: string) => void; + readonly onConnectProvider?: ( + providerName: string, + req?: ProductHostConnectRequest, + ) => void; /** `f` on a focused model row; runner owns the favorite persist + refresh. */ readonly onFavoriteToggle?: (id: string) => void; /** Alt+D on a focused model row; runner owns the default persist. */ @@ -166,8 +175,12 @@ export type RunnerHost = ProductHost & { /** * Open a command surface. Returns false when the requested surface has no * OpenTUI implementation, so the caller can report the gap. + * `connectScope` pre-scopes add-provider to one kind/profile (reconnects). */ - readonly openSurface: (kind: CommandSurfaceKind) => boolean; + readonly openSurface: ( + kind: CommandSurfaceKind, + connectScope?: ReconnectScope, + ) => boolean; /** * Recompute the models-first catalog from fresh recent/favorite refs and * push it into the already-open host — the picker's Recent/Favorites @@ -189,6 +202,16 @@ export type RunnerHost = ProductHost & { onAccept: (id: string) => void; onCancel: () => void; }) => boolean; + /** + * Idle-only one-action reconnect offer for a reconnect-class terminal + * failure. Single row, no type-to-filter, Enter re-keys via a pre-scoped + * /connect, Esc dismisses. Returns false when the run is not idle. + */ + readonly openReconnectRecovery: (args: { + scope: ReconnectScope; + onAccept: (id: string) => void; + onCancel: () => void; + }) => boolean; }; /** Map a subagent transcript entry to a stream row. */ @@ -457,6 +480,20 @@ export async function mountRunnerHost( return true; }; + const openReconnectRecovery: RunnerHost["openReconnectRecovery"] = (args) => { + if (host.shell.session.run !== "idle") return false; + openModelPickerOverlay(host.shell, { + items: [reconnectRecoveryItemLabel(args.scope)], + itemIds: [reconnectRecoveryItemId(args.scope)], + typeToFilter: false, + onCancel: args.onCancel, + onAccept: (selection) => { + if (selection.id !== undefined) args.onAccept(selection.id); + }, + }); + return true; + }; + const refreshModels = ( recentModels: readonly ModelCatalogRef[], favoriteModels: readonly ModelCatalogRef[], @@ -474,9 +511,11 @@ export async function mountRunnerHost( return { ...host, dispose, - openSurface: (kind) => openCommandSurface(host.shell, kind, surfaceDeps), + openSurface: (kind, connectScope) => + openCommandSurface(host.shell, kind, surfaceDeps, connectScope), refreshModels, refreshCostContext: pushCostContext, openCredentialRecovery, + openReconnectRecovery, }; } diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 4b49ea18a..74a7d371e 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -36,6 +36,7 @@ import { wireMcp } from "./mcp.js"; import { wirePostStartup } from "./wiring.js"; import { createRunnerState, liveAgent } from "./state.js"; import { applyCredentialRecoverySelection } from "./credential-recovery.js"; +import { createReconnectRecoveryPresenter } from "./reconnect-recovery.js"; import { applyStartupTheme } from "../theme-startup.js"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; @@ -248,6 +249,18 @@ export async function runTUI(initialConfig: Config): Promise { }); if (!opened) state.credentialRecovery.cancel(pending.generation); }; + // Settled reconnect-class failures surface the idle one-action reconnect + // offer: Enter re-keys the exact kind/profile scope that failed via a + // pre-scoped /connect (the phase-1 executor forwarding), Esc dismisses + // with no cascade to the credential picker, and a successful re-key + // replays the preserved turn once only when nothing committed. + state.presentReconnectRecovery = createReconnectRecoveryPresenter({ + recovery: state.reconnectRecovery, + openDialog: (dialog) => host.openReconnectRecovery(dialog), + openReconnect: (scope) => host.openSurface("add-provider", scope), + readDirector: () => services.directorHolder.instance, + deliverContinuation: (message) => liveAgent(state).deliver(message), + }); wirePostStartup(state, services, mcp.mcpConnectCallbacks); diff --git a/src/tui/runner/reconnect-recovery.test.ts b/src/tui/runner/reconnect-recovery.test.ts new file mode 100644 index 000000000..6130139b2 --- /dev/null +++ b/src/tui/runner/reconnect-recovery.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, test } from "bun:test"; +import type { InboundMessage } from "@intx/types/runtime"; +import { + applyReconnectRecoverySelection, + buildReconnectCommand, + createReconnectRecoveryPresenter, + createReconnectRecoveryState, + parseConnectScopeArgs, + reconnectRecoveryItemId, + reconnectRecoveryItemLabel, + type ReconnectScope, +} from "./reconnect-recovery.js"; + +function operatorMessage(): InboundMessage { + return { + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: "user@local", + to: ["agent@local"], + date: "2026-09-26T00:00:00.000Z", + messageId: "", + interchangeType: "conversation.message", + }, + flags: ["operator-originated"], + signatureStatus: "missing", + content: "inspect this", + }; +} + +function required(value: T | null, label: string): T { + if (value === null) throw new Error(`expected ${label}`); + return value; +} + +function credentialFailure(providerId: string) { + return { + type: "inference.error", + data: { + error: { category: "credential_failure", message: "401", providerId }, + }, + }; +} + +function credentialRetry() { + return { + type: "inference.retry", + data: { + previousError: { category: "credential_failure", message: "401" }, + }, + }; +} + +describe("reconnect recovery accept gate", () => { + test("arms for reconnect-class failures with a valid scope split", () => { + const state = createReconnectRecoveryState(); + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialRetry()); + state.observe(attempt, credentialFailure("xai/default-2")); + const pending = required(state.settle(attempt), "pending recovery"); + expect(pending.failedProvider).toBe("xai/default-2"); + expect(pending.scope).toEqual({ kind: "xai", profile: "default-2" }); + expect(pending.message.content).toBe("inspect this"); + expect(pending.committed).toBe(false); + }); + + test("does not arm without the retry, for non-credential terminals, or non-OAuth ids", () => { + const state = createReconnectRecoveryState(); + const oneFailure = state.begin(operatorMessage(), "xai"); + state.observe(oneFailure, credentialFailure("xai/default-2")); + expect(state.settle(oneFailure)).toBeNull(); + + const fatal = state.begin(operatorMessage(), "xai"); + state.observe(fatal, credentialRetry()); + state.observe(fatal, { + type: "inference.error", + data: { error: { category: "fatal", message: "boom" } }, + }); + expect(state.settle(fatal)).toBeNull(); + + const apiKey = state.begin(operatorMessage(), "custom"); + state.observe(apiKey, credentialRetry()); + state.observe(apiKey, credentialFailure("custom")); + expect(state.settle(apiKey)).toBeNull(); + }); + + test("cancel consumes the matching generation without reconnecting or replaying", () => { + const state = createReconnectRecoveryState(); + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialRetry()); + state.observe(attempt, credentialFailure("xai/default-2")); + const pending = required(state.settle(attempt), "pending recovery"); + expect(state.cancel(pending.generation)).toBe(true); + expect( + state.accept(pending.generation, reconnectRecoveryItemId(pending.scope)), + ).toEqual({ kind: "stale" }); + }); + + test("accept consumes once, validates generation + id, and vetoes replay after commitment", () => { + const state = createReconnectRecoveryState(); + const first = state.begin(operatorMessage(), "xai"); + state.observe(first, credentialRetry()); + state.observe(first, credentialFailure("xai/default-2")); + const pending = required(state.settle(first), "pending recovery"); + + expect( + state.accept( + pending.generation + 1, + reconnectRecoveryItemId(pending.scope), + ), + ).toEqual({ kind: "stale" }); + // A foreign id is invalid — and, like the sibling picker, consumes the + // pending offer so a wrong profile can never re-key the failed one. + expect(state.accept(pending.generation, "model:other/model")).toEqual({ + kind: "invalid", + }); + + const second = state.begin(operatorMessage(), "xai"); + state.observe(second, credentialRetry()); + state.observe(second, credentialFailure("xai/default-2")); + const pending2 = required(state.settle(second), "pending recovery"); + expect(pending2.scope).toEqual(pending.scope); + expect( + state.accept( + pending2.generation, + reconnectRecoveryItemId(pending2.scope), + ), + ).toEqual({ + kind: "accepted", + generation: pending2.generation, + scope: pending2.scope, + replay: true, + }); + expect( + state.accept( + pending2.generation, + reconnectRecoveryItemId(pending2.scope), + ), + ).toEqual({ kind: "stale" }); + + const committed = state.begin(operatorMessage(), "xai"); + state.observe(committed, credentialRetry()); + state.observe(committed, { type: "inference.tool-call", data: {} }); + state.observe(committed, credentialFailure("xai/default-2")); + const committedPending = required(state.settle(committed), "committed"); + expect( + state.accept( + committedPending.generation, + reconnectRecoveryItemId(committedPending.scope), + ), + ).toEqual({ + kind: "accepted", + generation: committedPending.generation, + scope: committedPending.scope, + replay: false, + }); + }); +}); + +describe("reconnect recovery selection", () => { + test("reconnects the failed scope and replays the turn once", () => { + const state = createReconnectRecoveryState(); + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialRetry()); + state.observe(attempt, credentialFailure("xai/default-2")); + const pending = required(state.settle(attempt), "pending recovery"); + + const seen: string[] = []; + let armed: number | null = null; + const delivered: InboundMessage[] = []; + const outcome = applyReconnectRecoverySelection({ + state, + generation: pending.generation, + selectedId: reconnectRecoveryItemId(pending.scope), + reconnect: (scope) => { + seen.push(`${scope.kind}/${scope.profile}`); + }, + armContinuation: (generation) => { + armed = generation; + }, + cancelContinuation: () => { + throw new Error("should not cancel"); + }, + deliverContinuation: (message) => { + delivered.push(message); + }, + }); + expect(outcome).toBe("continued"); + expect(seen).toEqual(["xai/default-2"]); + expect(armed ?? -1).toBe(pending.generation); + expect(delivered).toHaveLength(1); + }); + + test("reconnect failure and stale generations never arm a continuation", () => { + const state = createReconnectRecoveryState(); + expect( + applyReconnectRecoverySelection({ + state, + generation: 42, + selectedId: "reconnect:xai/default-2", + reconnect: () => undefined, + armContinuation: () => { + throw new Error("should not arm"); + }, + cancelContinuation: () => undefined, + deliverContinuation: () => undefined, + }), + ).toBe("stale"); + + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialRetry()); + state.observe(attempt, credentialFailure("xai/default-2")); + const pending = required(state.settle(attempt), "pending recovery"); + expect( + applyReconnectRecoverySelection({ + state, + generation: pending.generation, + selectedId: reconnectRecoveryItemId(pending.scope), + reconnect: () => { + throw new Error("browser closed"); + }, + armContinuation: () => { + throw new Error("should not arm"); + }, + cancelContinuation: () => undefined, + deliverContinuation: () => undefined, + }), + ).toBe("reconnect-failed"); + }); +}); + +describe("reconnect recovery presenter (runTUI wiring)", () => { + function settleArmed() { + const recovery = createReconnectRecoveryState(); + const attempt = recovery.begin(operatorMessage(), "xai"); + recovery.observe(attempt, credentialRetry()); + recovery.observe(attempt, credentialFailure("xai/default-2")); + return { + recovery, + pending: required(recovery.settle(attempt), "pending recovery"), + }; + } + + function wirePresenter(options?: { + dialogOpens?: boolean; + directorMissing?: boolean; + reconnectOpens?: boolean; + }) { + const dialogOpens = options?.dialogOpens ?? true; + const { recovery, pending } = settleArmed(); + type ReconnectDialog = { + scope: ReconnectScope; + onAccept: (id: string) => void; + onCancel: () => void; + }; + const dialogs: ReconnectDialog[] = []; + const reconnected: ReconnectScope[] = []; + const armed: number[] = []; + const cancelled: number[] = []; + const delivered: InboundMessage[] = []; + const present = createReconnectRecoveryPresenter({ + recovery, + openDialog: (next) => { + dialogs.push(next); + return dialogOpens; + }, + openReconnect: (scope) => { + reconnected.push(scope); + return options?.reconnectOpens ?? true; + }, + readDirector: () => + options?.directorMissing === true + ? undefined + : { + armCredentialRecoveryContinuation: (generation: number) => { + armed.push(generation); + }, + cancelCredentialRecoveryContinuation: (generation: number) => { + cancelled.push(generation); + }, + }, + deliverContinuation: (message) => { + delivered.push(message); + }, + }); + present(pending); + const dialog = dialogs.at(0); + if (dialog === undefined) throw new Error("expected open dialog"); + return { + recovery, + pending, + dialog, + reconnected, + armed, + cancelled, + delivered, + }; + } + + test("accept re-keys the failed scope and replays the turn once", () => { + const wired = wirePresenter(); + expect(wired.dialog.scope).toEqual(wired.pending.scope); + wired.dialog.onAccept(reconnectRecoveryItemId(wired.pending.scope)); + expect(wired.reconnected).toEqual([wired.pending.scope]); + expect(wired.armed).toEqual([wired.pending.generation]); + expect(wired.cancelled).toEqual([]); + // The continuation message carries a build-time timestamp, so pin the + // stable replay-binding fields rather than the whole message. + const continuation = wired.delivered.at(0); + if (continuation === undefined) throw new Error("expected continuation"); + expect(wired.delivered).toHaveLength(1); + expect(continuation.headers.messageId).toBe( + `credential-recovery-${wired.pending.generation}@local`, + ); + expect(continuation.headers.interchangeCorrelationId).toBe( + String(wired.pending.generation), + ); + // The generation is consumed: a late cancel is a no-op. + expect(wired.recovery.cancel(wired.pending.generation)).toBe(false); + }); + + test("dismiss cancels the generation without reconnecting or arming", () => { + const wired = wirePresenter(); + wired.dialog.onCancel(); + expect(wired.reconnected).toEqual([]); + expect(wired.armed).toEqual([]); + expect(wired.delivered).toEqual([]); + expect(wired.recovery.cancel(wired.pending.generation)).toBe(false); + }); + + test("closed dialog cancels the generation without reconnecting", () => { + const wired = wirePresenter({ dialogOpens: false }); + expect(wired.reconnected).toEqual([]); + expect(wired.armed).toEqual([]); + expect(wired.delivered).toEqual([]); + expect(wired.recovery.cancel(wired.pending.generation)).toBe(false); + }); + + test("unavailable reconnect surface never arms a continuation", () => { + const wired = wirePresenter({ reconnectOpens: false }); + wired.dialog.onAccept(reconnectRecoveryItemId(wired.pending.scope)); + expect(wired.reconnected).toEqual([wired.pending.scope]); + expect(wired.armed).toEqual([]); + expect(wired.delivered).toEqual([]); + }); + + test("missing director cancels without reconnecting or throwing", () => { + const wired = wirePresenter({ directorMissing: true }); + expect(() => + wired.dialog.onAccept(reconnectRecoveryItemId(wired.pending.scope)), + ).not.toThrow(); + expect(wired.reconnected).toEqual([]); + expect(wired.armed).toEqual([]); + expect(wired.delivered).toEqual([]); + expect(wired.recovery.cancel(wired.pending.generation)).toBe(false); + }); + + test("a foreign row id never re-keys the wrong profile", () => { + const wired = wirePresenter(); + wired.dialog.onAccept("reconnect:other/profile"); + expect(wired.reconnected).toEqual([]); + expect(wired.armed).toEqual([]); + expect(wired.delivered).toEqual([]); + }); +}); + +describe("reconnect descriptor", () => { + test("single row id/label spell the failed kind/profile", () => { + const scope = { kind: "xai", profile: "default-2" }; + expect(reconnectRecoveryItemId(scope)).toBe("reconnect:xai/default-2"); + expect(reconnectRecoveryItemLabel(scope)).toBe( + 'Reconnect xai/default-2 — re-authenticate "default-2"', + ); + expect(buildReconnectCommand(scope)).toBe("/connect xai default-2"); + }); + + test("/connect args parse to a kind/profile scope, defaulting the profile", () => { + expect(parseConnectScopeArgs("xai default-2")).toEqual({ + kind: "xai", + profile: "default-2", + }); + expect(parseConnectScopeArgs("xai")).toEqual({ + kind: "xai", + profile: "default", + }); + expect(parseConnectScopeArgs("")).toBeUndefined(); + expect(parseConnectScopeArgs("a b c")).toBeUndefined(); + expect(parseConnectScopeArgs("xai; rm")).toBeUndefined(); + }); +}); diff --git a/src/tui/runner/reconnect-recovery.ts b/src/tui/runner/reconnect-recovery.ts new file mode 100644 index 000000000..77d3debb1 --- /dev/null +++ b/src/tui/runner/reconnect-recovery.ts @@ -0,0 +1,281 @@ +/** + * Idle-only one-action reconnect offer for reconnect-class terminal failures + * (credential_failure on a known-OAuth provider id): re-authenticate the exact + * `kind/profile` scope that failed, then replay the turn once when nothing + * was committed. Mirrors credential-recovery's begin/observe/settle/accept + * shape on purpose — the two offers share one submit/exit seat and one + * continuation slot, so they must speak the same state language. + * + * Idle-only, no bare keys: like the credential-recovery picker, the surface is + * an overlay the runner opens when the run goes idle, so it never hijacks + * typing mid-turn (TUI.md §typography-and-key-handling, §slash-commands). + * Single action, no type-to-filter: Reconnect / — re-authenticate + * "". Enter re-keys via a pre-scoped /connect; Esc returns to the + * composer with /model + manual /connect still available. + */ + +import type { InboundMessage } from "@intx/types/runtime"; +import { isOperatorOriginated } from "../../agent/message-provenance.js"; +import { buildCredentialRecoveryContinuationMessage } from "./credential-recovery.js"; +import type { ReconnectScope } from "../connect-scope.js"; +import { splitReconnectScope } from "../../inference-error-message.js"; +import { isKnownOAuthProviderId } from "../../inference-gateway-error.js"; + +export interface PendingReconnectRecovery { + readonly generation: number; + readonly failedProvider: string; + readonly scope: ReconnectScope; + readonly message: InboundMessage; + readonly committed: boolean; +} + +export interface ReconnectRecoveryAttempt { + readonly generation: number; + failedProvider: string; + readonly message: InboundMessage; + committed: boolean; + sawCredentialRetry: boolean; + terminalReconnectFailure: boolean; +} + +type RecoveryEvent = { + readonly type: string; + readonly data?: unknown; +}; + +type RecoveryEventData = { + readonly previousError?: { readonly category?: string }; + readonly error?: { + readonly category?: string; + readonly providerId?: string; + }; +}; + +function recoveryEventData( + event: RecoveryEvent, +): RecoveryEventData | undefined { + if (typeof event.data !== "object" || event.data === null) return undefined; + return event.data as RecoveryEventData; +} + +export type ReconnectRecoveryAcceptance = + | { readonly kind: "stale" } + | { readonly kind: "invalid" } + | { + readonly kind: "accepted"; + readonly generation: number; + readonly scope: ReconnectScope; + readonly replay: boolean; + }; + +function isHarnessCommittingEvent(event: RecoveryEvent): boolean { + if (!event.type.startsWith("inference.")) return false; + // Keep the exact harness.ts isCommitting set. runInference handles these + // wrapper terminal events before consulting that predicate. + switch (event.type) { + case "inference.start": + case "inference.usage": + case "inference.done": + case "inference.error": + case "inference.retry": + return false; + default: + return true; + } +} + +/** + * Item id for the single reconnect row. Namespaced so accept() can reject + * foreign ids rather than re-keying the wrong profile. + */ +export function reconnectRecoveryItemId(scope: ReconnectScope): string { + return `reconnect:${scope.kind}/${scope.profile}`; +} + +export function reconnectRecoveryItemLabel(scope: ReconnectScope): string { + return `Reconnect ${scope.kind}/${scope.profile} — re-authenticate "${scope.profile}"`; +} + +export type { ReconnectScope } from "../connect-scope.js"; +export { + buildReconnectCommand, + parseConnectScopeArgs, +} from "../connect-scope.js"; + +export function createReconnectRecoveryState() { + let nextGeneration = 0; + let pending: PendingReconnectRecovery | null = null; + + return { + begin( + message: InboundMessage, + failedProvider: string, + ): ReconnectRecoveryAttempt { + if (isOperatorOriginated(message.flags)) pending = null; + return { + generation: ++nextGeneration, + failedProvider, + message, + committed: false, + sawCredentialRetry: false, + terminalReconnectFailure: false, + }; + }, + observe(attempt: ReconnectRecoveryAttempt, event: RecoveryEvent): void { + if (attempt.generation !== nextGeneration) return; + if (isHarnessCommittingEvent(event)) attempt.committed = true; + const data = recoveryEventData(event); + if ( + event.type === "inference.retry" && + data?.previousError?.category === "credential_failure" + ) { + attempt.sawCredentialRetry = true; + } + if (event.type === "inference.error") { + attempt.terminalReconnectFailure = + data?.error?.category === "credential_failure"; + const providerId = data?.error?.providerId; + if (providerId !== undefined && providerId.length > 0) { + attempt.failedProvider = providerId; + } + } + }, + settle(attempt: ReconnectRecoveryAttempt): PendingReconnectRecovery | null { + if ( + attempt.generation !== nextGeneration || + !isOperatorOriginated(attempt.message.flags) || + !attempt.sawCredentialRetry || + !attempt.terminalReconnectFailure || + !isKnownOAuthProviderId(attempt.failedProvider) + ) { + return null; + } + const scope = splitReconnectScope(attempt.failedProvider); + if (scope === undefined) return null; + pending = { + generation: attempt.generation, + failedProvider: attempt.failedProvider, + scope, + message: attempt.message, + committed: attempt.committed, + }; + return pending; + }, + cancel(generation: number): boolean { + if (pending?.generation !== generation) return false; + pending = null; + return true; + }, + accept( + generation: number, + selectedId: string, + ): ReconnectRecoveryAcceptance { + if (pending?.generation !== generation) return { kind: "stale" }; + const claimed = pending; + pending = null; + if (selectedId !== reconnectRecoveryItemId(claimed.scope)) { + return { kind: "invalid" }; + } + return { + kind: "accepted", + generation, + scope: claimed.scope, + replay: !claimed.committed, + }; + }, + clear(): void { + pending = null; + nextGeneration++; + }, + }; +} + +export function applyReconnectRecoverySelection(args: { + state: ReturnType; + generation: number; + selectedId: string; + reconnect: (scope: ReconnectScope) => void; + armContinuation: (generation: number) => void; + cancelContinuation: (generation: number) => void; + deliverContinuation: (message: InboundMessage) => void; +}): "stale" | "invalid" | "reconnect-failed" | "reconnected" | "continued" { + const acceptance = args.state.accept(args.generation, args.selectedId); + if (acceptance.kind !== "accepted") return acceptance.kind; + try { + args.reconnect(acceptance.scope); + } catch { + return "reconnect-failed"; + } + if (!acceptance.replay) return "reconnected"; + args.armContinuation(acceptance.generation); + try { + args.deliverContinuation( + buildCredentialRecoveryContinuationMessage(acceptance.generation), + ); + } catch { + args.cancelContinuation(acceptance.generation); + return "reconnected"; + } + return "continued"; +} + +/** + * Idle-offer presentation for a settled reconnect recovery: opens the + * one-action dialog and routes Enter into a pre-scoped /connect with + * replay-once when nothing committed, Esc into a clean cancel with no + * cascade to the credential picker. Mirrors the inline + * presentCredentialRecovery wiring in runner/index.ts on purpose — the two + * offers share one submit/exit seat, so the dismiss/accept shapes must stay + * identical. Factored (rather than inline like its sibling) so the wiring + * itself is unit-testable. + */ +export function createReconnectRecoveryPresenter(args: { + recovery: ReturnType; + openDialog: (dialog: { + scope: ReconnectScope; + onAccept: (id: string) => void; + onCancel: () => void; + }) => boolean; + openReconnect: (scope: ReconnectScope) => boolean; + readDirector: () => + | { + armCredentialRecoveryContinuation: (generation: number) => void; + cancelCredentialRecoveryContinuation: (generation: number) => void; + } + | undefined; + deliverContinuation: (message: InboundMessage) => void; +}): (pending: PendingReconnectRecovery) => void { + return (pending) => { + const opened = args.openDialog({ + scope: pending.scope, + onCancel: () => { + args.recovery.cancel(pending.generation); + }, + onAccept: (id) => { + const director = args.readDirector(); + if (director === undefined) { + args.recovery.cancel(pending.generation); + return; + } + applyReconnectRecoverySelection({ + state: args.recovery, + generation: pending.generation, + selectedId: id, + reconnect: (scope) => { + if (!args.openReconnect(scope)) { + throw new Error( + `reconnect surface unavailable for ${scope.kind}/${scope.profile}`, + ); + } + }, + armContinuation: (generation) => + director.armCredentialRecoveryContinuation(generation), + cancelContinuation: (generation) => + director.cancelCredentialRecoveryContinuation(generation), + deliverContinuation: args.deliverContinuation, + }); + }, + }); + if (!opened) args.recovery.cancel(pending.generation); + }; +} diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index 5c3243163..bc59062cf 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -58,6 +58,7 @@ import { warningsForPluginEntry } from "../../plugins/diagnostics.js"; import { isPluginEnabledForSurface } from "../plugin-surface.js"; import { resolveWaitForApproval } from "../tool-execution-watchdog.js"; import { hostOf, type RunnerServices, type RunnerState } from "./state.js"; +import type { ProductHostConnectRequest } from "../product-host.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -81,7 +82,10 @@ export function telemetryStartupNotice( export interface SettingsWiring { telemetryNotice: string | undefined; - onConnectProvider: (providerName: string) => void; + onConnectProvider: ( + providerName: string, + req?: ProductHostConnectRequest, + ) => void; onModelSelect: (id: string) => void; onFavoriteToggle: (id: string) => void; onSetDefault: (id: string) => void; @@ -241,7 +245,10 @@ export async function wireSettings( return false; }; - const onConnectProvider = (providerName: string): void => { + const onConnectProvider = ( + providerName: string, + req?: ProductHostConnectRequest, + ): void => { void (async () => { let result: Awaited>; // The setup surface shares the live session's renderer — a second @@ -252,6 +259,12 @@ export async function wireSettings( try { result = await connectProviderInline({ providerId: providerName, + // A pre-scoped reconnect (`/connect ` or the idle + // one-action offer) prefills the account-name step with the slug + // being re-keyed; the confirm-to-re-key still runs unchanged. + ...(req?.profile !== undefined + ? { initialOAuthProfile: req.profile } + : {}), settingsPath: trueGlobalSettingsPath, localSettingsPath: state.localSettingsFile, existing: state.config.settings ?? null, diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 806a9d77b..6c78013ba 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -41,6 +41,11 @@ import { type CredentialRecoveryAttempt, type PendingCredentialRecovery, } from "./credential-recovery.js"; +import { + createReconnectRecoveryState, + type PendingReconnectRecovery, + type ReconnectRecoveryAttempt, +} from "./reconnect-recovery.js"; import type { mountRunnerHost } from "./host.js"; import { EventEmitter } from "node:events"; @@ -271,9 +276,15 @@ export interface RunnerState { ProviderFailureAttempt, CredentialRecoveryAttempt >; + reconnectRecovery: ReturnType; + reconnectRecoveryAttempts: WeakMap< + ProviderFailureAttempt, + ReconnectRecoveryAttempt + >; // Late-wired cross-module callbacks, in original wiring order. presentCredentialRecovery?: (pending: PendingCredentialRecovery) => void; + presentReconnectRecovery?: (pending: PendingReconnectRecovery) => void; enqueueAgentDeliver?: ( deliverToLiveAgent: () => void, onSettle?: (result: AgentDeliveryResult) => void, @@ -417,6 +428,8 @@ export function createRunnerState(start: TUIStart): RunnerState { approvalPersistNotice: {}, credentialRecovery: createCredentialRecoveryState(), credentialRecoveryAttempts: new WeakMap(), + reconnectRecovery: createReconnectRecoveryState(), + reconnectRecoveryAttempts: new WeakMap(), }; // Saved through onboarding's "save anyway" bypass without a passing // connection test — warn now instead of a bare adapter error on first send. diff --git a/src/tui/runner/submit.test.ts b/src/tui/runner/submit.test.ts new file mode 100644 index 000000000..3d6cc97a3 --- /dev/null +++ b/src/tui/runner/submit.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import type { InboundMessage } from "@intx/types/runtime"; +import { + createCredentialRecoveryState, + type CredentialRecoveryAlternative, + type PendingCredentialRecovery, +} from "./credential-recovery.js"; +import { + createReconnectRecoveryState, + type PendingReconnectRecovery, +} from "./reconnect-recovery.js"; +import { presentSendRecoveryOffer } from "./submit.js"; + +function operatorMessage(): InboundMessage { + return { + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: "user@local", + to: ["agent@local"], + date: "2026-09-26T00:00:00.000Z", + messageId: "", + interchangeType: "conversation.message", + }, + flags: ["operator-originated"], + signatureStatus: "missing", + content: "inspect this", + }; +} + +function credentialRetry() { + return { + type: "inference.retry", + data: { + previousError: { category: "credential_failure", message: "401" }, + }, + }; +} + +function credentialFailure(providerId: string) { + return { + type: "inference.error", + data: { + error: { category: "credential_failure", message: "401", providerId }, + }, + }; +} + +const openAiAlternative: CredentialRecoveryAlternative = { + id: "openai/gpt-5", + label: "GPT-5 (openai)", + provider: "openai", + model: "gpt-5", +}; + +/** Drive a real credential state through one OAuth retry + terminal failure. */ +function settleCredential( + alternatives: readonly CredentialRecoveryAlternative[], +): PendingCredentialRecovery | null { + const state = createCredentialRecoveryState(); + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialRetry()); + state.observe(attempt, credentialFailure("xai/default-2")); + return state.settle(attempt, alternatives); +} + +/** Drive a real reconnect state through one OAuth retry + terminal failure. */ +function settleReconnectArmed(): { + state: ReturnType; + pending: PendingReconnectRecovery | null; +} { + const state = createReconnectRecoveryState(); + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialRetry()); + state.observe(attempt, credentialFailure("xai/default-2")); + return { state, pending: state.settle(attempt) }; +} + +function settleReconnectUnarmed(): PendingReconnectRecovery | null { + const state = createReconnectRecoveryState(); + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialFailure("xai/default-2")); + return state.settle(attempt); +} + +describe("presentSendRecoveryOffer precedence", () => { + test("reconnect wins when both offers arm; credential never presents", () => { + const credential = settleCredential([openAiAlternative]); + const { pending: reconnect } = settleReconnectArmed(); + expect(credential).not.toBeNull(); + expect(reconnect).not.toBeNull(); + const presented: string[] = []; + presentSendRecoveryOffer({ + credential, + reconnect, + presentCredentialRecovery: () => { + presented.push("credential"); + }, + presentReconnectRecovery: () => { + presented.push("reconnect"); + }, + }); + expect(presented).toEqual(["reconnect"]); + }); + + test("falls through to the credential picker when reconnect does not arm", () => { + const credential = settleCredential([openAiAlternative]); + const reconnect = settleReconnectUnarmed(); + expect(credential).not.toBeNull(); + expect(reconnect).toBeNull(); + const presented: string[] = []; + presentSendRecoveryOffer({ + credential, + reconnect, + presentCredentialRecovery: () => { + presented.push("credential"); + }, + presentReconnectRecovery: () => { + presented.push("reconnect"); + }, + }); + expect(presented).toEqual(["credential"]); + }); + + test("falls through to credential when reconnect arms but its presenter is absent", () => { + const credential = settleCredential([openAiAlternative]); + const { pending: reconnect } = settleReconnectArmed(); + expect(credential).not.toBeNull(); + expect(reconnect).not.toBeNull(); + const presented: string[] = []; + presentSendRecoveryOffer({ + credential, + reconnect, + presentCredentialRecovery: () => { + presented.push("credential"); + }, + // No presentReconnectRecovery: an unwired reconnect presenter must not + // swallow the credential fallback. + }); + expect(presented).toEqual(["credential"]); + }); + + test("presents reconnect when the credential picker has no alternatives", () => { + const credential = settleCredential([]); + const { pending: reconnect } = settleReconnectArmed(); + expect(credential).toBeNull(); + expect(reconnect).not.toBeNull(); + const presented: string[] = []; + presentSendRecoveryOffer({ + credential, + reconnect, + presentCredentialRecovery: () => { + presented.push("credential"); + }, + presentReconnectRecovery: () => { + presented.push("reconnect"); + }, + }); + expect(presented).toEqual(["reconnect"]); + }); + + test("dismissing the reconnect offer does not cascade to the credential picker", () => { + const credential = settleCredential([openAiAlternative]); + const { state, pending: reconnect } = settleReconnectArmed(); + expect(credential).not.toBeNull(); + expect(reconnect).not.toBeNull(); + const presented: string[] = []; + presentSendRecoveryOffer({ + credential, + reconnect, + presentCredentialRecovery: () => { + presented.push("credential"); + }, + presentReconnectRecovery: () => { + presented.push("reconnect"); + }, + }); + expect(presented).toEqual(["reconnect"]); + // Esc on the reconnect surface cancels that generation only; the + // credential offer settled for the same send stays unpresented. + expect(state.cancel(reconnect?.generation ?? -1)).toBe(true); + expect(presented).toEqual(["reconnect"]); + }); + + test("presents nothing when neither offer arms", () => { + const presented: string[] = []; + presentSendRecoveryOffer({ + credential: null, + reconnect: null, + presentCredentialRecovery: () => { + presented.push("credential"); + }, + presentReconnectRecovery: () => { + presented.push("reconnect"); + }, + }); + expect(presented).toEqual([]); + expect(() => + presentSendRecoveryOffer({ credential: null, reconnect: null }), + ).not.toThrow(); + }); +}); diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index 3c7f28c7d..a805d6f99 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -54,6 +54,8 @@ import { } from "./state.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; import { buildCredentialRecoveryAlternatives } from "./credential-recovery.js"; +import type { PendingCredentialRecovery } from "./credential-recovery.js"; +import type { PendingReconnectRecovery } from "./reconnect-recovery.js"; import { listCommands } from "../commands/registry.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -264,6 +266,34 @@ export function userInboundMessage( }; } +/** + * Present at most one recovery surface when a send settles. The reconnect + * offer re-auths the exact scope that failed, so it wins whenever it arms + * and its presenter is wired; otherwise fall through to the credential + * picker's provider switch. An armed reconnect with no presenter (a wiring + * gap, never the steady state) must not swallow the credential fallback. + * Dismissing the reconnect offer never cascades to the credential picker — + * one offer per failure; /model stays available for a manual switch. + */ +export function presentSendRecoveryOffer(args: { + credential: PendingCredentialRecovery | null; + reconnect: PendingReconnectRecovery | null; + presentCredentialRecovery?: + | ((pending: PendingCredentialRecovery) => void) + | undefined; + presentReconnectRecovery?: + | ((pending: PendingReconnectRecovery) => void) + | undefined; +}): void { + if (args.reconnect !== null && args.presentReconnectRecovery !== undefined) { + args.presentReconnectRecovery(args.reconnect); + return; + } + if (args.credential !== null) { + args.presentCredentialRecovery?.(args.credential); + } +} + /** * Wire the runtime submit path: system notices, the send-failure settle * path, attempt-tracked sends, and the full user-prompt send. @@ -343,6 +373,11 @@ export function createSubmitPath( attempt.providerId, ); state.credentialRecoveryAttempts.set(providerFailure, recoveryAttempt); + const reconnectAttempt = state.reconnectRecovery.begin( + message, + attempt.providerId, + ); + state.reconnectRecoveryAttempts.set(providerFailure, reconnectAttempt); try { await runWhileAgentBusy(state, async () => { const result = await send(message); @@ -375,7 +410,12 @@ export function createSubmitPath( recoveryAttempt.failedProvider, ), ); - if (pending !== null) state.presentCredentialRecovery?.(pending); + presentSendRecoveryOffer({ + credential: pending, + reconnect: state.reconnectRecovery.settle(reconnectAttempt), + presentCredentialRecovery: state.presentCredentialRecovery, + presentReconnectRecovery: state.presentReconnectRecovery, + }); services.providerFailureAttempts.sendSettled(providerFailure); } }; diff --git a/src/tui/stream-event-map.test.ts b/src/tui/stream-event-map.test.ts index 6cf170af4..86a7184f0 100644 --- a/src/tui/stream-event-map.test.ts +++ b/src/tui/stream-event-map.test.ts @@ -632,7 +632,7 @@ describe("inference.error text", () => { expect(out).toEqual([ { type: "assistant", - text: "xai/work Provider failed (credential_failure): HTTP 401. Authentication failed — run /connect to reconnect the provider profile.", + text: 'xai/work Provider failed (credential_failure): HTTP 401. Authentication failed — run /connect to reconnect the provider profile. Run "/connect xai work" to reconnect profile "work".', }, ]); }); From 4ddcf4ecb7a9847ed32595f701ea5d7fc404f63e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 2 Oct 2026 15:32:31 -0700 Subject: [PATCH 2/2] fix(providers): replay only after reconnect succeeds --- src/tui/command-surfaces.test.ts | 31 ++++++++++++ src/tui/command-surfaces.ts | 8 +++- src/tui/product-host.test.ts | 30 ++++++++++++ src/tui/product-host.ts | 13 +++++- src/tui/runner/host.ts | 11 ++++- src/tui/runner/index.ts | 3 +- src/tui/runner/reconnect-recovery.test.ts | 57 +++++++++++++++++++++-- src/tui/runner/reconnect-recovery.ts | 44 ++++++++++------- src/tui/runner/settings.ts | 13 +++++- 9 files changed, 182 insertions(+), 28 deletions(-) diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index c21f57916..e8d9d3281 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -1840,6 +1840,37 @@ describe("mcp surface", () => { }); describe("host-routed surfaces", () => { + test("scoped add-provider forwards its completion callback", async () => { + await withShell((shell) => { + let received: + | { + initialKind?: string; + initialProfile?: string; + onComplete?: (connected: boolean) => void; + } + | undefined; + const completed: boolean[] = []; + expect( + openCommandSurface( + shell, + "add-provider", + { + notify: () => undefined, + openAddProvider: (opts) => { + received = opts; + }, + }, + { kind: "xai", profile: "default-2" }, + (connected) => completed.push(connected), + ), + ).toBe(true); + expect(received?.initialKind).toBe("xai"); + expect(received?.initialProfile).toBe("default-2"); + received?.onComplete?.(true); + expect(completed).toEqual([true]); + }); + }); + test.each([ { surface: "models" as const, diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 2fae73882..0a5ea5400 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -238,12 +238,14 @@ export interface CommandSurfaceDeps { /** * Opens the host's add-provider selector (owned by the product host). * `/connect` omits returnToModels. `/connect [profile]` pre-scopes - * via initialKind/initialProfile (kind row focused, profile to the flow). + * via initialKind/initialProfile (kind row focused, profile to the flow), + * with completion reported after that interactive flow settles. */ readonly openAddProvider?: (opts?: { returnToModels?: boolean; initialKind?: string; initialProfile?: string; + onComplete?: (connected: boolean) => void; }) => void; /** Fallback channel for surfaces with no live data source. */ readonly notify: (text: string) => void; @@ -1666,6 +1668,7 @@ export function openCommandSurface( kind: CommandSurfaceKind, deps: CommandSurfaceDeps, connectScope?: ReconnectScope, + onConnectComplete?: (connected: boolean) => void, ): boolean { switch (kind) { case "help": @@ -1697,6 +1700,9 @@ export function openCommandSurface( ? { initialKind: connectScope.kind, initialProfile: connectScope.profile, + ...(onConnectComplete !== undefined + ? { onComplete: onConnectComplete } + : {}), } : undefined, ); diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 0747d6ee6..006c63832 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -1106,6 +1106,36 @@ describe("flat type-to-filter model picker", () => { } }); + test("scoped add-provider forwards completion with the reconnect request", async () => { + const completed: boolean[] = []; + let finish: ((connected: boolean) => void) | undefined; + const { harness, host } = await mountPicker({ + onConnectProvider: (_name, req) => { + expect(req?.kind).toBe("xai"); + expect(req?.profile).toBe("default-2"); + finish = req?.onComplete; + }, + addProviderChoices: () => [ + { id: "openai", label: "OpenAI", hint: "", accountCount: 0 }, + { id: "xai", label: "xAI", hint: "", accountCount: 1 }, + ], + }); + try { + host.openAddProvider?.({ + initialKind: "xai", + initialProfile: "default-2", + onComplete: (connected) => completed.push(connected), + }); + await harness.renderOnce(); + acceptOverlaySelection(host.shell); + finish?.(true); + expect(completed).toEqual([true]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + test("typed /connect then Enter opens add-provider and Esc leaves overlay null", async () => { const queued: { open?: () => void } = {}; const { harness, host } = await mountPicker({ diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 6aeaf998c..3af0b10cf 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -130,6 +130,8 @@ export interface ProductHostAddProviderChoice { export interface ProductHostConnectRequest { readonly kind?: string; readonly profile?: string; + /** Reports whether the interactive connect flow completed successfully. */ + readonly onComplete?: (connected: boolean) => void; } export interface ProductHostConfig { @@ -268,6 +270,7 @@ export interface ProductHost { returnToModels?: boolean; initialKind?: string; initialProfile?: string; + onComplete?: (connected: boolean) => void; }) => void; /** Swap the picker's rows/descriptions in place (e.g. after a provider connects). */ readonly setModels?: ( @@ -598,6 +601,7 @@ export async function mountProductHost( returnToModels?: boolean; initialKind?: string; initialProfile?: string; + onComplete?: (connected: boolean) => void; }) => void) | undefined; if (config.onModelSelect) { @@ -620,6 +624,7 @@ export async function mountProductHost( returnToModels?: boolean; initialKind?: string; initialProfile?: string; + onComplete?: (connected: boolean) => void; }): void => { const rows = addProviderChoices(); const scopedIndex = @@ -644,7 +649,13 @@ export async function mountProductHost( opts?.initialProfile !== undefined && (opts.initialKind === undefined || opts.initialKind === id) ) { - onConnect(id, { kind: id, profile: opts.initialProfile }); + onConnect(id, { + kind: id, + profile: opts.initialProfile, + ...(opts.onComplete !== undefined + ? { onComplete: opts.onComplete } + : {}), + }); return; } onConnect(id); diff --git a/src/tui/runner/host.ts b/src/tui/runner/host.ts index cf95b42a4..4b6671b1e 100644 --- a/src/tui/runner/host.ts +++ b/src/tui/runner/host.ts @@ -180,6 +180,7 @@ export type RunnerHost = ProductHost & { readonly openSurface: ( kind: CommandSurfaceKind, connectScope?: ReconnectScope, + onConnectComplete?: (connected: boolean) => void, ) => boolean; /** * Recompute the models-first catalog from fresh recent/favorite refs and @@ -511,8 +512,14 @@ export async function mountRunnerHost( return { ...host, dispose, - openSurface: (kind, connectScope) => - openCommandSurface(host.shell, kind, surfaceDeps, connectScope), + openSurface: (kind, connectScope, onConnectComplete) => + openCommandSurface( + host.shell, + kind, + surfaceDeps, + connectScope, + onConnectComplete, + ), refreshModels, refreshCostContext: pushCostContext, openCredentialRecovery, diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 74a7d371e..f2ebcb519 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -257,7 +257,8 @@ export async function runTUI(initialConfig: Config): Promise { state.presentReconnectRecovery = createReconnectRecoveryPresenter({ recovery: state.reconnectRecovery, openDialog: (dialog) => host.openReconnectRecovery(dialog), - openReconnect: (scope) => host.openSurface("add-provider", scope), + openReconnect: (scope, onComplete) => + host.openSurface("add-provider", scope, onComplete), readDirector: () => services.directorHolder.instance, deliverContinuation: (message) => liveAgent(state).deliver(message), }); diff --git a/src/tui/runner/reconnect-recovery.test.ts b/src/tui/runner/reconnect-recovery.test.ts index 6130139b2..74fe02e2e 100644 --- a/src/tui/runner/reconnect-recovery.test.ts +++ b/src/tui/runner/reconnect-recovery.test.ts @@ -157,7 +157,7 @@ describe("reconnect recovery accept gate", () => { }); describe("reconnect recovery selection", () => { - test("reconnects the failed scope and replays the turn once", () => { + test("reconnects the failed scope and replays only after success", () => { const state = createReconnectRecoveryState(); const attempt = state.begin(operatorMessage(), "xai"); state.observe(attempt, credentialRetry()); @@ -167,12 +167,14 @@ describe("reconnect recovery selection", () => { const seen: string[] = []; let armed: number | null = null; const delivered: InboundMessage[] = []; + let completeReconnect: ((connected: boolean) => void) | undefined; const outcome = applyReconnectRecoverySelection({ state, generation: pending.generation, selectedId: reconnectRecoveryItemId(pending.scope), - reconnect: (scope) => { + reconnect: (scope, onComplete) => { seen.push(`${scope.kind}/${scope.profile}`); + completeReconnect = onComplete; }, armContinuation: (generation) => { armed = generation; @@ -184,10 +186,45 @@ describe("reconnect recovery selection", () => { delivered.push(message); }, }); - expect(outcome).toBe("continued"); + expect(outcome).toBe("reconnected"); expect(seen).toEqual(["xai/default-2"]); + expect(armed).toBeNull(); + expect(delivered).toEqual([]); + completeReconnect?.(true); expect(armed ?? -1).toBe(pending.generation); expect(delivered).toHaveLength(1); + completeReconnect?.(true); + expect(delivered).toHaveLength(1); + }); + + test("cancel, failure, and committed attempts never replay", () => { + for (const connected of [false, true]) { + const state = createReconnectRecoveryState(); + const attempt = state.begin(operatorMessage(), "xai"); + state.observe(attempt, credentialRetry()); + if (connected) { + state.observe(attempt, { type: "inference.tool-call", data: {} }); + } + state.observe(attempt, credentialFailure("xai/default-2")); + const pending = required(state.settle(attempt), "pending recovery"); + let completeReconnect: ((result: boolean) => void) | undefined; + applyReconnectRecoverySelection({ + state, + generation: pending.generation, + selectedId: reconnectRecoveryItemId(pending.scope), + reconnect: (_scope, onComplete) => { + completeReconnect = onComplete; + }, + armContinuation: () => { + throw new Error("should not arm"); + }, + cancelContinuation: () => undefined, + deliverContinuation: () => { + throw new Error("should not deliver"); + }, + }); + completeReconnect?.(connected); + } }); test("reconnect failure and stale generations never arm a continuation", () => { @@ -254,6 +291,7 @@ describe("reconnect recovery presenter (runTUI wiring)", () => { }; const dialogs: ReconnectDialog[] = []; const reconnected: ReconnectScope[] = []; + let completeReconnect: ((connected: boolean) => void) | undefined; const armed: number[] = []; const cancelled: number[] = []; const delivered: InboundMessage[] = []; @@ -263,8 +301,9 @@ describe("reconnect recovery presenter (runTUI wiring)", () => { dialogs.push(next); return dialogOpens; }, - openReconnect: (scope) => { + openReconnect: (scope, onComplete?: (connected: boolean) => void) => { reconnected.push(scope); + completeReconnect = onComplete; return options?.reconnectOpens ?? true; }, readDirector: () => @@ -290,17 +329,22 @@ describe("reconnect recovery presenter (runTUI wiring)", () => { pending, dialog, reconnected, + completeReconnect: (connected: boolean) => completeReconnect?.(connected), armed, cancelled, delivered, }; } - test("accept re-keys the failed scope and replays the turn once", () => { + test("accept waits for a successful re-key before replaying the turn once", () => { const wired = wirePresenter(); expect(wired.dialog.scope).toEqual(wired.pending.scope); wired.dialog.onAccept(reconnectRecoveryItemId(wired.pending.scope)); expect(wired.reconnected).toEqual([wired.pending.scope]); + expect(wired.armed).toEqual([]); + expect(wired.delivered).toEqual([]); + + wired.completeReconnect(true); expect(wired.armed).toEqual([wired.pending.generation]); expect(wired.cancelled).toEqual([]); // The continuation message carries a build-time timestamp, so pin the @@ -314,6 +358,9 @@ describe("reconnect recovery presenter (runTUI wiring)", () => { expect(continuation.headers.interchangeCorrelationId).toBe( String(wired.pending.generation), ); + wired.completeReconnect(true); + expect(wired.armed).toEqual([wired.pending.generation]); + expect(wired.delivered).toHaveLength(1); // The generation is consumed: a late cancel is a no-op. expect(wired.recovery.cancel(wired.pending.generation)).toBe(false); }); diff --git a/src/tui/runner/reconnect-recovery.ts b/src/tui/runner/reconnect-recovery.ts index 77d3debb1..6c5b5c28e 100644 --- a/src/tui/runner/reconnect-recovery.ts +++ b/src/tui/runner/reconnect-recovery.ts @@ -194,29 +194,36 @@ export function applyReconnectRecoverySelection(args: { state: ReturnType; generation: number; selectedId: string; - reconnect: (scope: ReconnectScope) => void; + reconnect: ( + scope: ReconnectScope, + onComplete: (connected: boolean) => void, + ) => void; armContinuation: (generation: number) => void; cancelContinuation: (generation: number) => void; deliverContinuation: (message: InboundMessage) => void; -}): "stale" | "invalid" | "reconnect-failed" | "reconnected" | "continued" { +}): "stale" | "invalid" | "reconnect-failed" | "reconnected" { const acceptance = args.state.accept(args.generation, args.selectedId); if (acceptance.kind !== "accepted") return acceptance.kind; + let completed = false; + const onComplete = (connected: boolean): void => { + if (completed) return; + completed = true; + if (!connected || !acceptance.replay) return; + args.armContinuation(acceptance.generation); + try { + args.deliverContinuation( + buildCredentialRecoveryContinuationMessage(acceptance.generation), + ); + } catch { + args.cancelContinuation(acceptance.generation); + } + }; try { - args.reconnect(acceptance.scope); + args.reconnect(acceptance.scope, onComplete); } catch { return "reconnect-failed"; } - if (!acceptance.replay) return "reconnected"; - args.armContinuation(acceptance.generation); - try { - args.deliverContinuation( - buildCredentialRecoveryContinuationMessage(acceptance.generation), - ); - } catch { - args.cancelContinuation(acceptance.generation); - return "reconnected"; - } - return "continued"; + return "reconnected"; } /** @@ -236,7 +243,10 @@ export function createReconnectRecoveryPresenter(args: { onAccept: (id: string) => void; onCancel: () => void; }) => boolean; - openReconnect: (scope: ReconnectScope) => boolean; + openReconnect: ( + scope: ReconnectScope, + onComplete: (connected: boolean) => void, + ) => boolean; readDirector: () => | { armCredentialRecoveryContinuation: (generation: number) => void; @@ -261,8 +271,8 @@ export function createReconnectRecoveryPresenter(args: { state: args.recovery, generation: pending.generation, selectedId: id, - reconnect: (scope) => { - if (!args.openReconnect(scope)) { + reconnect: (scope, onComplete) => { + if (!args.openReconnect(scope, onComplete)) { throw new Error( `reconnect surface unavailable for ${scope.kind}/${scope.profile}`, ); diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index bc59062cf..d2b99de06 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -250,6 +250,12 @@ export async function wireSettings( req?: ProductHostConnectRequest, ): void => { void (async () => { + let completionReported = false; + const reportCompletion = (connected: boolean): void => { + if (completionReported) return; + completionReported = true; + req?.onComplete?.(connected); + }; let result: Awaited>; // The setup surface shares the live session's renderer — a second // CliRenderer cannot exist on the same stdin. Shell input stays @@ -281,6 +287,7 @@ export async function wireSettings( createRenderer: () => Promise.resolve(hostOf(state).renderer), }); } catch (err) { + reportCompletion(false); state.systemNotice?.( `Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`, ); @@ -291,7 +298,11 @@ export async function wireSettings( // whatever shell zone owned it before the surface mounted. applyFocus(hostOf(state).shell); } - if (!result.connected) return; + if (!result.connected) { + reportCompletion(false); + return; + } + reportCompletion(true); const onDisk = await loadSettings(trueGlobalSettingsPath); const resolvedForCatalog: ResolvedProvider = {