From 30115b2497c31628249f08082ec07dcbd505ef76 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 17 Jun 2026 11:57:10 +0000 Subject: [PATCH 1/2] fix(init): rotate spinner messages during long plan-codemods wait During plan-codemods, the spinner showed static text for up to ~3min while the server fetched SDK docs and ran the planner agent. Users assumed the CLI had hung and bailed. Add client-side rotating progress messages that cycle every 12s during resumeWithRecovery() for steps with known long waits (plan-codemods, detect-platform). After exhausting all messages, append elapsed time so the user knows the system is still working. Fixes #1107 --- src/lib/init/clack-utils.ts | 29 +++++ src/lib/init/wizard-runner.ts | 73 +++++++++-- test/lib/init/clack-utils.test.ts | 30 +++++ test/lib/init/wizard-runner.test.ts | 191 ++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+), 9 deletions(-) diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index 7999ead84..987ef67b1 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -218,3 +218,32 @@ export const STEP_LABELS_SHORT: Record = { export function shortStepLabel(stepId: string): string { return STEP_LABELS_SHORT[stepId] ?? STEP_LABELS[stepId] ?? stepId; } + +/** + * Rotating progress messages shown while the CLI waits on a long-running + * server-side phase that doesn't emit intermediate suspends. The messages + * cycle on a timer so the spinner text changes and the UI doesn't look + * frozen. + * + * Only steps with known long waits need entries here. Steps that suspend + * frequently (read-files, apply-codemods) already update the spinner via + * the suspend payload's `detail` field. + */ +export const STEP_PROGRESS_MESSAGES: Record = { + "plan-codemods": [ + "Fetching SDK documentation...", + "Analyzing integration requirements...", + "Generating code modification plan...", + "Reviewing planned changes for correctness...", + "Finalizing integration plan...", + ], + "detect-platform": [ + "Scanning project files...", + "Identifying framework and language...", + "Analyzing project configuration...", + "Determining SDK compatibility...", + ], +}; + +/** Interval between rotating progress messages (ms). */ +export const PROGRESS_ROTATE_INTERVAL_MS = 12_000; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 2bb039185..24e974da7 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -33,8 +33,10 @@ import { import { logger } from "../logger.js"; import { abortIfCancelled, + PROGRESS_ROTATE_INTERVAL_MS, STEP_ACTIVE_LABELS, STEP_LABELS, + STEP_PROGRESS_MESSAGES, WizardCancelledError, } from "./clack-utils.js"; import { @@ -216,6 +218,50 @@ function describePostTool(payload: SuspendPayload): string | undefined { } } +/** + * Start a rotating progress message timer for steps that have long + * server-side phases without intermediate suspends. Returns a cleanup + * function that stops the timer. + * + * The timer cycles through {@link STEP_PROGRESS_MESSAGES} for the given + * step, updating the spinner text every {@link PROGRESS_ROTATE_INTERVAL_MS}. + * After exhausting all messages, it appends elapsed time so the user + * knows the system is still working. + */ +function startProgressRotation( + stepId: string, + spin: SpinnerHandle, + spinState: SpinState +): () => void { + const messages = STEP_PROGRESS_MESSAGES[stepId]; + if (!messages || messages.length === 0) { + return () => { + // No rotating messages for this step — no-op cleanup. + }; + } + + let index = -1; + const startedAt = Date.now(); + + const timer = setInterval(() => { + if (!spinState.running) { + return; + } + index += 1; + if (index < messages.length) { + spin.message(messages[index]); + } else { + const elapsedSec = Math.round((Date.now() - startedAt) / 1000); + const lastMessage = messages.at(-1) ?? messages[0]; + spin.message(`${lastMessage} (${elapsedSec}s)`); + } + }, PROGRESS_ROTATE_INTERVAL_MS); + + return () => { + clearInterval(timer); + }; +} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: suspend handling needs to branch across tool and interactive payload kinds async function handleSuspendedStep( ctx: StepContext, @@ -956,16 +1002,25 @@ export async function runWizard(initialOptions: WizardOptions): Promise { stepHistory ); - result = await resumeWithRecovery({ - run, - workflow, - stepId: extracted.stepId, - payload: extracted.payload, - resumeData, - tracingOptions, + const stopProgress = startProgressRotation( + extracted.stepId, spin, - ui, - }); + spinState + ); + try { + result = await resumeWithRecovery({ + run, + workflow, + stepId: extracted.stepId, + payload: extracted.payload, + resumeData, + tracingOptions, + spin, + ui, + }); + } finally { + stopProgress(); + } } } catch (err) { const isAuthFailure = err instanceof ApiError && err.status === 401; diff --git a/test/lib/init/clack-utils.test.ts b/test/lib/init/clack-utils.test.ts index 7fce76d97..21782c455 100644 --- a/test/lib/init/clack-utils.test.ts +++ b/test/lib/init/clack-utils.test.ts @@ -9,6 +9,9 @@ import { abortIfCancelled, featureHint, featureLabel, + PROGRESS_ROTATE_INTERVAL_MS, + STEP_ACTIVE_LABELS, + STEP_PROGRESS_MESSAGES, sortFeatures, WizardCancelledError, } from "../../../src/lib/init/clack-utils.js"; @@ -125,3 +128,30 @@ describe("sortFeatures", () => { ]); }); }); + +describe("STEP_PROGRESS_MESSAGES", () => { + test("plan-codemods has multiple rotating messages", () => { + const messages = STEP_PROGRESS_MESSAGES["plan-codemods"]; + expect(messages).toBeDefined(); + expect(messages!.length).toBeGreaterThanOrEqual(3); + for (const msg of messages!) { + expect(msg).toMatch(/\.\.\.$/); + } + }); + + test("detect-platform has multiple rotating messages", () => { + const messages = STEP_PROGRESS_MESSAGES["detect-platform"]; + expect(messages).toBeDefined(); + expect(messages!.length).toBeGreaterThanOrEqual(2); + }); + + test("every step with progress messages also has an active label", () => { + for (const stepId of Object.keys(STEP_PROGRESS_MESSAGES)) { + expect(STEP_ACTIVE_LABELS[stepId]).toBeDefined(); + } + }); + + test("rotation interval is a positive number", () => { + expect(PROGRESS_ROTATE_INTERVAL_MS).toBeGreaterThan(0); + }); +}); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 41c79c613..0831b0e3a 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -1652,3 +1652,194 @@ describe("runWizard — additional coverage", () => { expect(messages.some((m) => m.includes("javascript-nextjs"))).toBe(true); }); }); + +describe("runWizard — progress rotation for long-running steps", () => { + test("rotates spinner messages during plan-codemods resume", async () => { + vi.useFakeTimers(); + const toolPayload = { + type: "tool" as const, + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["src/app.tsx"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["plan-codemods"]], + steps: { "plan-codemods": { suspendPayload: toolPayload } }, + }; + + // resumeAsync will block until we advance timers, then resolve + let resolveResume!: (value: unknown) => void; + const resumePromise = new Promise((resolve) => { + resolveResume = resolve; + }); + const resumeAsyncMock = vi.fn(() => resumePromise); + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return { + createRun: vi.fn(() => + Promise.resolve({ + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: resumeAsyncMock, + }) + ), + runById: runByIdMock, + } as any; + }); + + const runPromise = runWizard(makeOptions()); + + // Let the wizard start and reach the resume call + await vi.advanceTimersByTimeAsync(100); + + // Advance past one rotation interval + await vi.advanceTimersByTimeAsync(12_000); + + // Check that the spinner received a rotating message + const messagesAfterFirstRotation = spinnerMock.message.mock.calls.map( + (c: unknown[]) => c[0] as string + ); + expect( + messagesAfterFirstRotation.some((m) => + m.includes("Fetching SDK documentation") + ) + ).toBe(true); + + // Advance past another rotation interval + await vi.advanceTimersByTimeAsync(12_000); + + const messagesAfterSecondRotation = spinnerMock.message.mock.calls.map( + (c: unknown[]) => c[0] as string + ); + expect( + messagesAfterSecondRotation.some((m) => + m.includes("Analyzing integration requirements") + ) + ).toBe(true); + + // Resolve the resume and let the wizard finish + resolveResume({ status: "success" }); + await vi.advanceTimersByTimeAsync(100); + await runPromise; + }); + + test("appends elapsed time after exhausting all progress messages", async () => { + vi.useFakeTimers(); + const toolPayload = { + type: "tool" as const, + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["src/app.tsx"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["plan-codemods"]], + steps: { "plan-codemods": { suspendPayload: toolPayload } }, + }; + + let resolveResume!: (value: unknown) => void; + const resumePromise = new Promise((resolve) => { + resolveResume = resolve; + }); + const resumeAsyncMock = vi.fn(() => resumePromise); + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return { + createRun: vi.fn(() => + Promise.resolve({ + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: resumeAsyncMock, + }) + ), + runById: runByIdMock, + } as any; + }); + + const runPromise = runWizard(makeOptions()); + await vi.advanceTimersByTimeAsync(100); + + // Advance past all 5 plan-codemods messages (5 * 12s = 60s) + // plus one more interval to trigger the elapsed time display + await vi.advanceTimersByTimeAsync(72_000); + + const messages = spinnerMock.message.mock.calls.map( + (c: unknown[]) => c[0] as string + ); + // After exhausting messages, should show elapsed time + expect(messages.some((m) => /\(\d+s\)/.test(m))).toBe(true); + + resolveResume({ status: "success" }); + await vi.advanceTimersByTimeAsync(100); + await runPromise; + }); + + test("does not rotate messages for steps without progress messages", async () => { + vi.useFakeTimers(); + const toolPayload = { + type: "tool" as const, + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["install-deps"]], + steps: { "install-deps": { suspendPayload: toolPayload } }, + }; + + let resolveResume!: (value: unknown) => void; + const resumePromise = new Promise((resolve) => { + resolveResume = resolve; + }); + const resumeAsyncMock = vi.fn(() => resumePromise); + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return { + createRun: vi.fn(() => + Promise.resolve({ + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: resumeAsyncMock, + }) + ), + runById: runByIdMock, + } as any; + }); + + const runPromise = runWizard(makeOptions()); + await vi.advanceTimersByTimeAsync(100); + + const messagesBefore = spinnerMock.message.mock.calls.length; + + // Advance past a rotation interval + await vi.advanceTimersByTimeAsync(12_000); + + const messagesAfter = spinnerMock.message.mock.calls.length; + // No new messages should have been added by the rotation timer + expect(messagesAfter).toBe(messagesBefore); + + resolveResume({ status: "success" }); + await vi.advanceTimersByTimeAsync(100); + await runPromise; + }); +}); From a7639addab2d68d58206dcfee86df8ab8f5777a6 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 17 Jun 2026 12:11:16 +0000 Subject: [PATCH 2/2] fix: pause progress rotation during reconnect recovery The rotation timer could overwrite 'Reconnecting...' spinner messages during stale-step or connection recovery. Add pause/resume to the rotation handle so resumeWithRecovery can suppress ticks while recovery is in progress. --- src/lib/init/wizard-runner.ts | 62 ++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 24e974da7..7457f6035 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -218,33 +218,60 @@ function describePostTool(payload: SuspendPayload): string | undefined { } } +type ProgressRotationHandle = { + /** Stop the rotation timer permanently. */ + stop: () => void; + /** + * Pause rotation so recovery paths (e.g. "Reconnecting...") can + * set the spinner without the next tick overwriting it. + */ + pause: () => void; + /** Resume rotation after a paused recovery completes. */ + resume: () => void; +}; + +const NOOP_ROTATION: ProgressRotationHandle = { + stop: () => { + // No rotating messages for this step. + }, + pause: () => { + // noop + }, + resume: () => { + // noop + }, +}; + /** * Start a rotating progress message timer for steps that have long - * server-side phases without intermediate suspends. Returns a cleanup - * function that stops the timer. + * server-side phases without intermediate suspends. Returns a handle + * to stop or pause the timer. * * The timer cycles through {@link STEP_PROGRESS_MESSAGES} for the given * step, updating the spinner text every {@link PROGRESS_ROTATE_INTERVAL_MS}. * After exhausting all messages, it appends elapsed time so the user * knows the system is still working. + * + * The handle exposes `pause()`/`resume()` so that recovery paths inside + * `resumeWithRecovery` can temporarily suppress rotation while showing + * "Reconnecting..." without the next tick overwriting it. */ function startProgressRotation( stepId: string, spin: SpinnerHandle, spinState: SpinState -): () => void { +): ProgressRotationHandle { const messages = STEP_PROGRESS_MESSAGES[stepId]; if (!messages || messages.length === 0) { - return () => { - // No rotating messages for this step — no-op cleanup. - }; + return NOOP_ROTATION; } let index = -1; + let paused = false; const startedAt = Date.now(); const timer = setInterval(() => { - if (!spinState.running) { + if (!spinState.running || paused) { return; } index += 1; @@ -257,8 +284,16 @@ function startProgressRotation( } }, PROGRESS_ROTATE_INTERVAL_MS); - return () => { - clearInterval(timer); + return { + stop: () => { + clearInterval(timer); + }, + pause: () => { + paused = true; + }, + resume: () => { + paused = false; + }, }; } @@ -580,6 +615,7 @@ type ResumeRetryArgs = { tracingOptions: Record; spin: SpinnerHandle; ui: WizardUI; + progressRotation?: ProgressRotationHandle; }; /** @@ -706,6 +742,7 @@ async function resumeWithRecovery( tracingOptions, spin, ui, + progressRotation, } = args; try { const raw = await withTimeout( @@ -719,6 +756,7 @@ async function resumeWithRecovery( return assertWorkflowResult(raw); } catch (err) { if (isStepAlreadyAdvancedError(err)) { + progressRotation?.pause(); spin.message("Reconnecting..."); const recovered = await tryRecoverCurrentRunState( workflow, @@ -750,6 +788,7 @@ async function resumeWithRecovery( throw err; } + progressRotation?.pause(); ui.setOverlay?.({ kind: "health", message: "Connection interrupted, reconnecting...", @@ -1002,7 +1041,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { stepHistory ); - const stopProgress = startProgressRotation( + const progressRotation = startProgressRotation( extracted.stepId, spin, spinState @@ -1017,9 +1056,10 @@ export async function runWizard(initialOptions: WizardOptions): Promise { tracingOptions, spin, ui, + progressRotation, }); } finally { - stopProgress(); + progressRotation.stop(); } } } catch (err) {