From 1ec948118ec93ec60d9fd3eecbbcdbe340b3b455 Mon Sep 17 00:00:00 2001 From: kt Date: Fri, 10 Jul 2026 15:47:49 -0700 Subject: [PATCH] stop onboarding from gating normal tui use (#377) * fix(coding-agent): stop onboarding from gating tui use (ENG-4537) * fix(coding-agent): retain deferred initial prompts * style(coding-agent): remove redundant onboarding comments --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/settings-manager.ts | 11 +- .../src/modes/interactive/interactive-mode.ts | 137 ++++-------- .../src/modes/interactive/onboarding.ts | 7 +- .../test/interactive-mode-status.test.ts | 196 +++++++++++------- .../test/onboarding-startup.test.ts | 26 +-- .../test/settings-manager.test.ts | 16 +- .../4537-nonblocking-onboarding.test.ts | 44 ++++ 8 files changed, 234 insertions(+), 204 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/4537-nonblocking-onboarding.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bd5a6fa56..928575116 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Changed the Agents View and new-chat splashes to keep one blank row above the butterfly. - Fixed Agents View retrying after an intentional daemon shutdown instead of stopping with restart guidance. - Fixed stale heartbeat jobs reopening archived, deleted, or concurrently terminated sessions ([ENG-4519](https://linear.app/primeintellect/issue/ENG-4519/heartbeats-rebirth-sessions-that-were-previously-killed)). +- Fixed onboarding blocking normal TUI use by reopening login or model selection after startup ([ENG-4537](https://linear.app/primeintellect/issue/ENG-4537/stop-onboarding-from-gating-normal-tui-use)). - Fixed IPython Bash cells with leading blank lines being labeled and previewed as Python ([ENG-4529](https://linear.app/primeintellect/issue/ENG-4529/leading-newline-before-percentpercentbash-names-tool-call-as-python)). ## [0.2.8] - 2026-07-09 diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 739c653b0..c424b8b46 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -120,6 +120,7 @@ export type McpServerConfig = }; export interface Settings { + onboardingShown?: boolean; onboardingCompleted?: boolean; defaultProvider?: string; defaultModel?: string; @@ -616,13 +617,13 @@ export class SettingsManager { return drained; } - getOnboardingCompleted(): boolean { - return this.settings.onboardingCompleted ?? false; + getOnboardingShown(): boolean { + return this.settings.onboardingShown ?? this.settings.onboardingCompleted ?? false; } - setOnboardingCompleted(completed: boolean): void { - this.globalSettings.onboardingCompleted = completed; - this.markModified("onboardingCompleted"); + setOnboardingShown(shown: boolean): void { + this.globalSettings.onboardingShown = shown; + this.markModified("onboardingShown"); this.save(); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1ca660dc8..df2638af9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -190,12 +190,7 @@ import type { InteractiveModeLocalToolRendererDefinition, InteractiveModeUiServices, } from "./interactive-mode-services.js"; -import { - isOnboardingModelReady, - type OnboardingStartupState, - shouldRunOnboarding, - shouldRunPrimeCliOnboardingSplash, -} from "./onboarding.js"; +import { type OnboardingStartupState, shouldRunOnboarding, shouldRunPrimeCliOnboardingSplash } from "./onboarding.js"; import { formatResumeHint } from "./resume-hint.js"; import { getAvailableThemes, @@ -414,7 +409,7 @@ type GoalAnnouncementSnapshot = { type ModelSelectionResult = { status: "selected" } | { status: "cancelled" } | { status: "action"; actionId: string }; -type ModelFallbackWarningAction = "show" | "suppress" | "wait"; +type ModelFallbackWarningAction = "show" | "suppress"; const MODEL_SELECTOR_ACTIONS: readonly ModelSelectorAction[] = [ { id: "add_provider", label: "Add provider...", description: "subscription or API key" }, @@ -1247,6 +1242,13 @@ export class InteractiveMode { if (initialPromptsSent) { return; } + if (!initialMessage && !initialMessages?.length) { + initialPromptsSent = true; + return; + } + if (!this.getCurrentModel()) { + return; + } initialPromptsSent = true; if (initialMessage) { @@ -1270,10 +1272,9 @@ export class InteractiveMode { } }; - const needsOnboarding = this.shouldRunOnboarding(); let deferredStartupNotificationsShown = false; const showDeferredStartupNotifications = () => { - if (deferredStartupNotificationsShown || this.shouldRunOnboarding()) { + if (deferredStartupNotificationsShown) { return; } deferredStartupNotificationsShown = true; @@ -1315,31 +1316,18 @@ export class InteractiveMode { if (modelFallbackWarningShown) { return; } - const action = this.getModelFallbackWarningAction(modelFallbackMessage, needsOnboarding); - if (action === "wait") { - return; - } + const action = this.getModelFallbackWarningAction(modelFallbackMessage); modelFallbackWarningShown = true; if (action === "show" && modelFallbackMessage) { this.showWarning(modelFallbackMessage); } }; - if (!needsOnboarding) { - showModelFallbackWarning(); - } - - let promptReady = !needsOnboarding; - if (needsOnboarding) { - promptReady = await this.runOnboardingFlow(); - } - - if (promptReady) { - showDeferredStartupNotifications(); - showModelFallbackWarning(); - void this.maybeWarnAboutAnthropicSubscriptionAuth(); - await sendInitialPrompts(); - } + await this.runStartupOnboarding(); + showDeferredStartupNotifications(); + showModelFallbackWarning(); + void this.maybeWarnAboutAnthropicSubscriptionAuth(); + await sendInitialPrompts(); // Main interactive loop while (true) { @@ -1347,11 +1335,6 @@ export class InteractiveMode { if (userInput === undefined || this.returnToAgentsViewRequested) { return "agents_view"; } - if (!(await this.ensurePromptReady())) { - this.editor.setText(userInput); - this.showStatus("Complete onboarding to send the restored prompt."); - continue; - } showDeferredStartupNotifications(); showModelFallbackWarning(); await sendInitialPrompts(); @@ -1368,10 +1351,7 @@ export class InteractiveMode { } } - private getModelFallbackWarningAction( - modelFallbackMessage: string | undefined, - startupNeededOnboarding: boolean, - ): ModelFallbackWarningAction { + private getModelFallbackWarningAction(modelFallbackMessage: string | undefined): ModelFallbackWarningAction { if (!modelFallbackMessage) { return "suppress"; } @@ -1381,12 +1361,6 @@ export class InteractiveMode { if (isNoModelsAvailableMessage(modelFallbackMessage) && this.getCurrentModel()) { return "suppress"; } - if (startupNeededOnboarding && isNoModelsAvailableMessage(modelFallbackMessage) && !this.shouldRunOnboarding()) { - return "suppress"; - } - if (startupNeededOnboarding && isNoModelsAvailableMessage(modelFallbackMessage)) { - return "wait"; - } return "show"; } @@ -1406,81 +1380,48 @@ export class InteractiveMode { return shouldRunPrimeCliOnboardingSplash(this.getOnboardingState()); } - private isCurrentModelReady(): boolean { - return isOnboardingModelReady(this.getOnboardingState()); - } - - private completeOnboarding(): void { - if (!this.settingsManager.getOnboardingCompleted()) { - this.settingsManager.setOnboardingCompleted(true); - } - } - - private completeOnboardingIfCurrentModelReady(): void { - if (this.isCurrentModelReady()) { - this.completeOnboarding(); - } - } - - private isOnboardingResolvedAfterModelPrompt(selectedModel: boolean): boolean { - if (selectedModel) { - this.completeOnboarding(); - return true; + private markOnboardingShown(): void { + if (!this.settingsManager.getOnboardingShown()) { + this.settingsManager.setOnboardingShown(true); } - return !this.shouldRunOnboarding(); } - private async ensurePromptReady(): Promise { + private async runStartupOnboarding(): Promise { if (!this.shouldRunOnboarding()) { - return true; + return false; } - return this.runOnboardingFlow(); + + const showPrimeCliSplash = this.shouldRunPrimeCliOnboardingSplash(); + this.markOnboardingShown(); + await this.settingsManager.flush(); + await this.runOnboardingFlow(showPrimeCliSplash); + return true; } - private async runOnboardingFlow(): Promise { + private async runOnboardingFlow(showPrimeCliSplash = this.shouldRunPrimeCliOnboardingSplash()): Promise { this.modelRegistry.refresh(); - if (this.shouldRunPrimeCliOnboardingSplash()) { + if (showPrimeCliSplash) { const shouldContinue = await this.showOnboardingModelSelectionSplash(); if (!shouldContinue) { - this.showStatus("Model selection required. Use /model to continue."); - return false; - } - - const selectedModel = await this.promptForModelSelection({ allowProviderSetup: true }); - if (this.isOnboardingResolvedAfterModelPrompt(selectedModel)) { - return true; + return; } - this.showStatus("Model selection required. Use /model to continue."); - return false; + await this.promptForModelSelection({ allowProviderSetup: true }); + return; } const availableModels = await this.getModelCandidates(); if (availableModels.length > 0) { - const selectedModel = await this.promptForModelSelection({ allowProviderSetup: true }); - if (this.isOnboardingResolvedAfterModelPrompt(selectedModel)) { - return true; - } - - this.showStatus("Model selection required. Use /model to continue."); - return false; + await this.promptForModelSelection({ allowProviderSetup: true }); + return; } const authResult = await this.showOnboardingPrimeLogin(); if (authResult.status !== "success") { - this.showStatus( - "Prime Intellect login required for onboarding. Use /login to configure other providers later.", - ); - return false; - } - - const selectedModel = await this.promptForModelSelection({ allowProviderSetup: true }); - if (this.isOnboardingResolvedAfterModelPrompt(selectedModel)) { - return true; + return; } - this.showStatus("Model selection required. Use /model to continue."); - return false; + await this.promptForModelSelection({ allowProviderSetup: true }); } private getMarkdownThemeWithSettings(): MarkdownTheme { @@ -6654,7 +6595,6 @@ export class InteractiveMode { try { this.showStatus(`Switching model: ${model.id}`); await this.applySelectedModel(model); - this.completeOnboardingIfCurrentModelReady(); this.showStatus(`Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); @@ -6962,7 +6902,6 @@ export class InteractiveMode { this.showStatus(`Switching model: ${model.id}`); try { await this.applySelectedModel(model); - this.completeOnboardingIfCurrentModelReady(); this.showStatus(`Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); @@ -7561,10 +7500,8 @@ export class InteractiveMode { private async showOAuthSelector(mode: "login" | "logout", loginOptions: ProviderLoginOptions = {}): Promise { if (mode === "login") { const authResult = await this.showLoginProviderSelector(loginOptions); - // Service credentials don't change the model, so skip the model picker. if (authResult.status === "success" && authResult.kind !== "service") { this.invalidateConnectionModels(); - await this.promptForModelSelection(); } // An MCP integration login enables its skill, so reload resources — but a // reload is refused mid-turn, so tell the user to /reload (matching /mcp login). diff --git a/packages/coding-agent/src/modes/interactive/onboarding.ts b/packages/coding-agent/src/modes/interactive/onboarding.ts index e576c11e2..ed8ebb116 100644 --- a/packages/coding-agent/src/modes/interactive/onboarding.ts +++ b/packages/coding-agent/src/modes/interactive/onboarding.ts @@ -3,7 +3,7 @@ import type { AuthStatus } from "../../core/auth-storage.js"; import { PRIME_INFERENCE_PROVIDER_ID } from "../../core/prime-inference-auth.js"; export interface OnboardingSettingsReader { - getOnboardingCompleted(): boolean; + getOnboardingShown(): boolean; } export interface OnboardingModelRegistryReader { @@ -19,7 +19,7 @@ export interface OnboardingStartupState { } export function shouldRunPrimeCliOnboardingSplash(state: OnboardingStartupState): boolean { - if (state.settingsManager.getOnboardingCompleted()) { + if (state.settingsManager.getOnboardingShown()) { return false; } if (!state.model || state.model.provider !== PRIME_INFERENCE_PROVIDER_ID) { @@ -34,6 +34,9 @@ export function isOnboardingModelReady(state: OnboardingStartupState): boolean { } export function shouldRunOnboarding(state: OnboardingStartupState): boolean { + if (state.settingsManager.getOnboardingShown()) { + return false; + } state.modelRegistry.refresh(); if (shouldRunPrimeCliOnboardingSplash(state)) { return true; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 47adf528a..3087ea993 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -859,65 +859,41 @@ describe("InteractiveMode transcript rebuild", () => { describe("InteractiveMode startup onboarding warnings", () => { type StartupWarningHarness = { - shouldRunOnboarding(): boolean; getCurrentModel(): AgentConnectionModel | undefined; - getModelFallbackWarningAction( - modelFallbackMessage: string | undefined, - startupNeededOnboarding: boolean, - ): "show" | "suppress" | "wait"; + getModelFallbackWarningAction(modelFallbackMessage: string | undefined): "show" | "suppress"; }; const getModelFallbackWarningAction = (InteractiveMode.prototype as unknown as StartupWarningHarness) .getModelFallbackWarningAction; - const createHarness = (options: { - shouldRunOnboarding?: boolean; - currentModel?: AgentConnectionModel; - }): StartupWarningHarness => ({ - shouldRunOnboarding: vi.fn(() => options.shouldRunOnboarding ?? false), + const createHarness = (options: { currentModel?: AgentConnectionModel }): StartupWarningHarness => ({ getCurrentModel: vi.fn(() => options.currentModel), getModelFallbackWarningAction, }); const liveModel = { id: "gpt-5.5", provider: "prime-inference" } as AgentConnectionModel; - test("suppresses the stale no-model warning after onboarding selects a model", () => { - const fakeThis = createHarness({ shouldRunOnboarding: false }); - - expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage(), true)).toBe("suppress"); - expect(fakeThis.shouldRunOnboarding).toHaveBeenCalledTimes(1); - }); - - test("waits to suppress the stale no-model warning while onboarding is still needed", () => { - const fakeThis = createHarness({ shouldRunOnboarding: true }); - - expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage(), true)).toBe("wait"); - expect(fakeThis.shouldRunOnboarding).toHaveBeenCalledTimes(1); - }); - test("suppresses the no-model warning when the live session has a model", () => { const fakeThis = createHarness({ currentModel: liveModel }); - expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage(), false)).toBe("suppress"); + expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage())).toBe("suppress"); }); test("shows the no-model warning when the live session has no model", () => { const fakeThis = createHarness({}); - expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage(), false)).toBe("show"); + expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage())).toBe("show"); }); test("keeps real model restore fallback warnings after onboarding", () => { - const fakeThis = createHarness({ shouldRunOnboarding: false, currentModel: liveModel }); + const fakeThis = createHarness({ currentModel: liveModel }); expect( getModelFallbackWarningAction.call( fakeThis, "Could not restore model anthropic/claude-old. Using prime-inference/openai/gpt-5.5.", - true, ), ).toBe("show"); - expect(fakeThis.shouldRunOnboarding).not.toHaveBeenCalled(); }); }); @@ -1008,7 +984,6 @@ describe("InteractiveMode model selection persistence", () => { checkDaxnutsEasterEgg(model: AgentConnectionModel): void; applySelectedModel(model: AgentConnectionModel): Promise; handleModelCommand(searchTerm?: string): Promise; - completeOnboardingIfCurrentModelReady(): void; setupAutocompleteProvider(): void; }; type ModelSelectorOverlayHarness = { @@ -1045,7 +1020,6 @@ describe("InteractiveMode model selection persistence", () => { initialSearchInput?: string, options?: { actions?: ReadonlyArray; subtitle?: string }, ): Promise<{ status: string; actionId?: string }>; - completeOnboardingIfCurrentModelReady(): void; maybeWarnAboutAnthropicSubscriptionAuth(model: AgentConnectionModel): Promise; checkDaxnutsEasterEgg(model: AgentConnectionModel): void; setupAutocompleteProvider(): void; @@ -1120,7 +1094,6 @@ describe("InteractiveMode model selection persistence", () => { return { hide }; }); fakeThis.showModelSelectorAsync = overlayPrototype.showModelSelectorAsync; - fakeThis.completeOnboardingIfCurrentModelReady = vi.fn(); fakeThis.maybeWarnAboutAnthropicSubscriptionAuth = vi.fn(async () => {}); fakeThis.checkDaxnutsEasterEgg = vi.fn(); fakeThis.setupAutocompleteProvider = vi.fn(); @@ -1214,7 +1187,6 @@ describe("InteractiveMode model selection persistence", () => { fakeThis.findExactModelMatch = vi.fn(async () => model); fakeThis.maybeWarnAboutAnthropicSubscriptionAuth = vi.fn(async () => {}); fakeThis.checkDaxnutsEasterEgg = vi.fn(); - fakeThis.completeOnboardingIfCurrentModelReady = vi.fn(); fakeThis.setupAutocompleteProvider = vi.fn(); await fakeThis.handleModelCommand("gpt-5.5"); @@ -1224,7 +1196,6 @@ describe("InteractiveMode model selection persistence", () => { expect(fakeThis.patchConnectionState).toHaveBeenCalledWith({ model, availableThinkingLevels: ["off"] }); expect(fakeThis.showStatus).toHaveBeenCalledWith("Model: gpt-5.5"); expect(fakeThis.showError).not.toHaveBeenCalled(); - expect(fakeThis.completeOnboardingIfCurrentModelReady).toHaveBeenCalledTimes(1); }); test("opens the model selector before live model refresh resolves", async () => { @@ -1561,9 +1532,9 @@ describe("InteractiveMode model selection persistence", () => { describe("InteractiveMode Prime CLI onboarding", () => { type OnboardingHarness = { shouldRunOnboarding(): boolean; - completeOnboarding(): void; - handleModelCommand(searchTerm?: string): Promise; - runOnboardingFlow(): Promise; + markOnboardingShown(): void; + runStartupOnboarding(): Promise; + runOnboardingFlow(showPrimeCliSplash?: boolean): Promise; applySelectedModel(model: AgentConnectionModel): Promise; setupAutocompleteProvider(): void; }; @@ -1581,9 +1552,10 @@ describe("InteractiveMode Prime CLI onboarding", () => { getProviderAuthStatus: (provider: string) => AuthStatus; }; settingsManager: { - getOnboardingCompleted: () => boolean; - setOnboardingCompleted: (completed: boolean) => void; + getOnboardingShown: () => boolean; + setOnboardingShown: (shown: boolean) => void; setDefaultModelAndProvider: (provider: string, modelId: string) => void; + flush: () => Promise; }; }; footer?: { invalidate: () => void }; @@ -1597,14 +1569,12 @@ describe("InteractiveMode Prime CLI onboarding", () => { showOnboardingModelSelectionSplash?: () => Promise; showOnboardingPrimeLogin?: () => Promise<{ status: string }>; promptForModelSelection?: (options?: { allowProviderSetup?: boolean }) => Promise; - completeOnboardingIfCurrentModelReady?: () => void; getModelCandidates?: () => Promise; }; const shouldRunOnboarding = (InteractiveMode.prototype as unknown as OnboardingHarness).shouldRunOnboarding; - const completeOnboarding = (InteractiveMode.prototype as unknown as OnboardingHarness).completeOnboarding; - const handleModelCommand = (InteractiveMode.prototype as unknown as OnboardingHarness).handleModelCommand; + const markOnboardingShown = (InteractiveMode.prototype as unknown as OnboardingHarness).markOnboardingShown; + const runStartupOnboarding = (InteractiveMode.prototype as unknown as OnboardingHarness).runStartupOnboarding; const runOnboardingFlow = (InteractiveMode.prototype as unknown as OnboardingHarness).runOnboardingFlow; - const applySelectedModel = (InteractiveMode.prototype as unknown as OnboardingHarness).applySelectedModel; const primeModel: AgentConnectionModel = { id: "openai/gpt-5.5", @@ -1624,7 +1594,44 @@ describe("InteractiveMode Prime CLI onboarding", () => { maxTokens: 128000, } as AgentConnectionModel; - function createPrimeCliHarness(completed: boolean): OnboardingFake { + test("defers CLI initial prompts until a model is selected after onboarding dismissal", async () => { + let currentModel: AgentConnectionModel | undefined; + const prompt = vi.fn(async () => {}); + const getUserInput = vi + .fn<() => Promise>() + .mockImplementationOnce(async () => { + currentModel = primeModel; + return "interactive prompt"; + }) + .mockResolvedValueOnce(undefined); + const fakeThis = { + init: vi.fn(async () => {}), + options: { + agentsViewOwnsStartupNotices: true, + initialMessage: "initial prompt", + }, + modelRegistry: { getError: vi.fn(() => undefined) }, + runStartupOnboarding: vi.fn(async () => true), + getModelFallbackWarningAction: vi.fn(() => "suppress"), + getCurrentModel: vi.fn(() => currentModel), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), + getUserInput, + collectImagesFor: vi.fn(() => []), + agentConnection: { prompt }, + showWarning: vi.fn(), + showError: vi.fn(), + returnToAgentsViewRequested: false, + sessionHasMessages: false, + }; + + await expect(InteractiveMode.prototype.run.call(fakeThis as never)).resolves.toBe("agents_view"); + + expect(prompt).toHaveBeenNthCalledWith(1, "initial prompt", { images: undefined }); + expect(prompt).toHaveBeenNthCalledWith(2, "interactive prompt", { images: [] }); + expect(prompt).toHaveBeenCalledTimes(2); + }); + + function createPrimeCliHarness(shown: boolean): OnboardingFake { const fakeThis = Object.create(InteractiveMode.prototype) as OnboardingFake; fakeThis.connectionState = createConnectionState({ model: primeModel }); fakeThis.connectionModels = [primeModel]; @@ -1643,9 +1650,10 @@ describe("InteractiveMode Prime CLI onboarding", () => { ), }, settingsManager: { - getOnboardingCompleted: vi.fn(() => completed), - setOnboardingCompleted: vi.fn(), + getOnboardingShown: vi.fn(() => shown), + setOnboardingShown: vi.fn(), setDefaultModelAndProvider: vi.fn(), + flush: vi.fn(async () => {}), }, }; fakeThis.getModelCandidates = vi.fn(async () => [primeModel]); @@ -1659,62 +1667,55 @@ describe("InteractiveMode Prime CLI onboarding", () => { expect(fakeThis.uiServices.modelRegistry.refresh).toHaveBeenCalledTimes(1); }); - test("skips Prime CLI onboarding after it has been completed", () => { + test("skips Prime CLI onboarding after it has been shown", () => { const fakeThis = createPrimeCliHarness(true); expect(shouldRunOnboarding.call(fakeThis)).toBe(false); }); - test("persists onboarding completion once", () => { + test("persists that onboarding was shown once", () => { const fakeThis = createPrimeCliHarness(false); - completeOnboarding.call(fakeThis); + markOnboardingShown.call(fakeThis); - expect(fakeThis.uiServices.settingsManager.setOnboardingCompleted).toHaveBeenCalledWith(true); + expect(fakeThis.uiServices.settingsManager.setOnboardingShown).toHaveBeenCalledWith(true); }); - test("manual exact model selection completes Prime CLI onboarding", async () => { - let completed = false; + test("persists onboarding before opening the one-shot flow", async () => { + let shown = false; + let flushed = false; const fakeThis = createPrimeCliHarness(false); - fakeThis.connectionState = createConnectionState({ model: undefined }); - fakeThis.uiServices.settingsManager.getOnboardingCompleted = vi.fn(() => completed); - fakeThis.uiServices.settingsManager.setOnboardingCompleted = vi.fn((nextCompleted: boolean) => { - completed = nextCompleted; + fakeThis.uiServices.settingsManager.getOnboardingShown = vi.fn(() => shown); + fakeThis.uiServices.settingsManager.setOnboardingShown = vi.fn((nextShown: boolean) => { + shown = nextShown; }); - fakeThis.agentConnection.setModel = vi.fn(async (_provider: string, _modelId: string) => { - fakeThis.connectionState = { ...fakeThis.connectionState, model: primeModel }; + fakeThis.uiServices.settingsManager.flush = vi.fn(async () => { + flushed = true; }); - fakeThis.findExactModelMatch = vi.fn(async () => primeModel); - fakeThis.footer = { invalidate: vi.fn() }; - fakeThis.updateEditorBorderColor = vi.fn(); - fakeThis.patchConnectionState = vi.fn((patch: Partial) => { - fakeThis.connectionState = { ...fakeThis.connectionState, ...patch }; + fakeThis.runOnboardingFlow = vi.fn(async (showPrimeCliSplash?: boolean) => { + expect(showPrimeCliSplash).toBe(true); + expect(shown).toBe(true); + expect(flushed).toBe(true); }); - fakeThis.showStatus = vi.fn(); - fakeThis.showError = vi.fn(); - fakeThis.maybeWarnAboutAnthropicSubscriptionAuth = vi.fn(); - fakeThis.checkDaxnutsEasterEgg = vi.fn(); - fakeThis.applySelectedModel = applySelectedModel; - fakeThis.setupAutocompleteProvider = vi.fn(); - await handleModelCommand.call(fakeThis, "prime-inference/openai/gpt-5.5"); + await expect(runStartupOnboarding.call(fakeThis)).resolves.toBe(true); - expect(fakeThis.agentConnection.setModel).toHaveBeenCalledWith(PRIME_INFERENCE_PROVIDER_ID, "openai/gpt-5.5"); - expect(fakeThis.uiServices.settingsManager.setOnboardingCompleted).toHaveBeenCalledWith(true); - expect(shouldRunOnboarding.call(fakeThis)).toBe(false); + expect(fakeThis.uiServices.settingsManager.setOnboardingShown).toHaveBeenCalledWith(true); + expect(fakeThis.uiServices.settingsManager.flush).toHaveBeenCalledTimes(1); + expect(fakeThis.runOnboardingFlow).toHaveBeenCalledWith(true); }); - test("cancelled model picker does not complete Prime CLI onboarding", async () => { + test("cancelled model picker exits onboarding without a required-selection warning", async () => { const fakeThis = createPrimeCliHarness(false); fakeThis.showOnboardingModelSelectionSplash = vi.fn(async () => true); fakeThis.promptForModelSelection = vi.fn(async () => false); fakeThis.showStatus = vi.fn(); - await expect(runOnboardingFlow.call(fakeThis)).resolves.toBe(false); + await expect(runOnboardingFlow.call(fakeThis)).resolves.toBeUndefined(); expect(fakeThis.promptForModelSelection).toHaveBeenCalledWith({ allowProviderSetup: true }); - expect(fakeThis.uiServices.settingsManager.setOnboardingCompleted).not.toHaveBeenCalled(); - expect(fakeThis.showStatus).toHaveBeenCalledWith("Model selection required. Use /model to continue."); + expect(fakeThis.uiServices.settingsManager.setOnboardingShown).not.toHaveBeenCalled(); + expect(fakeThis.showStatus).not.toHaveBeenCalled(); }); test("uses live connection models before falling back to Prime login during onboarding", async () => { @@ -1724,7 +1725,7 @@ describe("InteractiveMode Prime CLI onboarding", () => { fakeThis.promptForModelSelection = vi.fn(async () => true); fakeThis.showOnboardingPrimeLogin = vi.fn(async () => ({ status: "success" })); - await expect(runOnboardingFlow.call(fakeThis)).resolves.toBe(true); + await expect(runOnboardingFlow.call(fakeThis)).resolves.toBeUndefined(); expect(fakeThis.getModelCandidates).toHaveBeenCalledTimes(1); expect(fakeThis.promptForModelSelection).toHaveBeenCalledWith({ allowProviderSetup: true }); @@ -1742,14 +1743,49 @@ describe("InteractiveMode Prime CLI onboarding", () => { fakeThis.promptForModelSelection = vi.fn(async () => false); fakeThis.showStatus = vi.fn(); - await expect(runOnboardingFlow.call(fakeThis)).resolves.toBe(true); + await expect(runOnboardingFlow.call(fakeThis)).resolves.toBeUndefined(); expect(fakeThis.promptForModelSelection).toHaveBeenCalledWith({ allowProviderSetup: true }); - expect(fakeThis.uiServices.settingsManager.setOnboardingCompleted).not.toHaveBeenCalled(); + expect(fakeThis.uiServices.settingsManager.setOnboardingShown).not.toHaveBeenCalled(); expect(fakeThis.showStatus).not.toHaveBeenCalled(); }); }); +describe("InteractiveMode explicit login", () => { + type LoginHarness = { + showOAuthSelector(mode: "login" | "logout"): Promise; + showLoginProviderSelector(): Promise<{ + status: "success"; + providerId: string; + providerName: string; + authType: "oauth"; + kind: "provider"; + }>; + invalidateConnectionModels(): void; + promptForModelSelection(): Promise; + }; + + const showOAuthSelector = (InteractiveMode.prototype as unknown as LoginHarness).showOAuthSelector; + + test("does not open model selection after provider login", async () => { + const fakeThis = Object.create(InteractiveMode.prototype) as LoginHarness; + fakeThis.showLoginProviderSelector = vi.fn(async () => ({ + status: "success" as const, + providerId: "anthropic", + providerName: "Anthropic", + authType: "oauth" as const, + kind: "provider" as const, + })); + fakeThis.invalidateConnectionModels = vi.fn(); + fakeThis.promptForModelSelection = vi.fn(async () => true); + + await showOAuthSelector.call(fakeThis, "login"); + + expect(fakeThis.invalidateConnectionModels).toHaveBeenCalledTimes(1); + expect(fakeThis.promptForModelSelection).not.toHaveBeenCalled(); + }); +}); + describe("InteractiveMode splash cwd display", () => { test("formats home-relative cwd paths", () => { expect(formatSplashCwd(homedir())).toBe("~"); diff --git a/packages/coding-agent/test/onboarding-startup.test.ts b/packages/coding-agent/test/onboarding-startup.test.ts index 5678eadcd..36bd49f6c 100644 --- a/packages/coding-agent/test/onboarding-startup.test.ts +++ b/packages/coding-agent/test/onboarding-startup.test.ts @@ -13,14 +13,14 @@ function makeModel(provider: string): Model { } function makeState(overrides: { - onboardingCompleted: boolean; + onboardingShown: boolean; model: Model | undefined; modelHasAuth?: boolean; primeAuthSource?: AuthStatus["source"]; }): OnboardingStartupState { return { settingsManager: { - getOnboardingCompleted: () => overrides.onboardingCompleted, + getOnboardingShown: () => overrides.onboardingShown, }, modelRegistry: { refresh: () => {}, @@ -37,7 +37,7 @@ function makeState(overrides: { describe("startup onboarding decision", () => { test("runs onboarding on first launch with Prime CLI auth", () => { const state = makeState({ - onboardingCompleted: false, + onboardingShown: false, model: makeModel(PRIME_INFERENCE_PROVIDER_ID), modelHasAuth: true, primeAuthSource: "prime_cli", @@ -46,21 +46,23 @@ describe("startup onboarding decision", () => { expect(shouldRunOnboarding(state)).toBe(true); }); - test("runs onboarding when no model is available", () => { - expect(shouldRunOnboarding(makeState({ onboardingCompleted: true, model: undefined }))).toBe(true); + test("runs onboarding on first launch when no model is available", () => { + expect(shouldRunOnboarding(makeState({ onboardingShown: false, model: undefined }))).toBe(true); }); - test("runs onboarding when the current model has no configured auth", () => { + test("does not reopen onboarding after dismissal when the current model has no local auth", () => { expect( - shouldRunOnboarding( - makeState({ onboardingCompleted: true, model: makeModel("anthropic"), modelHasAuth: false }), - ), - ).toBe(true); + shouldRunOnboarding(makeState({ onboardingShown: true, model: makeModel("anthropic"), modelHasAuth: false })), + ).toBe(false); + }); + + test("does not reopen onboarding after dismissal when no model is available", () => { + expect(shouldRunOnboarding(makeState({ onboardingShown: true, model: undefined }))).toBe(false); }); test("skips onboarding once completed with a ready model", () => { const state = makeState({ - onboardingCompleted: true, + onboardingShown: true, model: makeModel(PRIME_INFERENCE_PROVIDER_ID), modelHasAuth: true, primeAuthSource: "prime_cli", @@ -71,7 +73,7 @@ describe("startup onboarding decision", () => { test("skips the Prime CLI splash for non-Prime providers with ready auth", () => { const state = makeState({ - onboardingCompleted: false, + onboardingShown: false, model: makeModel("anthropic"), modelHasAuth: true, primeAuthSource: "stored", diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index c09b17df0..451d2241e 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -197,18 +197,24 @@ describe("SettingsManager", () => { }); }); - describe("onboardingCompleted", () => { + describe("onboardingShown", () => { it("defaults to false and persists globally", async () => { const manager = SettingsManager.create(projectDir, agentDir); - expect(manager.getOnboardingCompleted()).toBe(false); + expect(manager.getOnboardingShown()).toBe(false); - manager.setOnboardingCompleted(true); + manager.setOnboardingShown(true); await manager.flush(); const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); - expect(savedSettings.onboardingCompleted).toBe(true); - expect(manager.getOnboardingCompleted()).toBe(true); + expect(savedSettings.onboardingShown).toBe(true); + expect(manager.getOnboardingShown()).toBe(true); + }); + + it("treats the legacy completion field as already shown", () => { + const manager = SettingsManager.inMemory({ onboardingCompleted: true }); + + expect(manager.getOnboardingShown()).toBe(true); }); }); diff --git a/packages/coding-agent/test/suite/regressions/4537-nonblocking-onboarding.test.ts b/packages/coding-agent/test/suite/regressions/4537-nonblocking-onboarding.test.ts new file mode 100644 index 000000000..50b9fe43e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4537-nonblocking-onboarding.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { shouldRunOnboarding } from "../../../src/modes/interactive/onboarding.js"; +import { createHarness, type Harness } from "../harness.js"; + +describe("ENG-4537 non-blocking onboarding", () => { + const harnesses: Harness[] = []; + afterEach(() => { + for (const harness of harnesses.splice(0)) { + harness.cleanup(); + } + }); + + test("never reopens shown onboarding for a client-local auth mismatch", async () => { + const harness = await createHarness({ + settings: { onboardingShown: true }, + withConfiguredAuth: false, + }); + harnesses.push(harness); + + expect( + shouldRunOnboarding({ + settingsManager: harness.settingsManager, + modelRegistry: harness.session.modelRegistry, + model: harness.session.model, + }), + ).toBe(false); + }); + + test("never reopens shown onboarding when the daemon session has no model", async () => { + const harness = await createHarness({ + settings: { onboardingShown: true }, + withConfiguredAuth: false, + }); + harnesses.push(harness); + + expect( + shouldRunOnboarding({ + settingsManager: harness.settingsManager, + modelRegistry: harness.session.modelRegistry, + model: undefined, + }), + ).toBe(false); + }); +}); -- 2.54.0