Skip to content

fix(tui): surface post-agent work so the wizard doesn't look hung#443

Open
kelsonpw wants to merge 5 commits intomainfrom
kelsonpw/cli-hang-fix
Open

fix(tui): surface post-agent work so the wizard doesn't look hung#443
kelsonpw wants to merge 5 commits intomainfrom
kelsonpw/cli-hang-fix

Conversation

@kelsonpw
Copy link
Copy Markdown
Collaborator

@kelsonpw kelsonpw commented Apr 30, 2026

Summary

After the Claude agent finished, the TUI sat on Screen.Run with all agent task checkmarks ✓ while a static "Creating charts and dashboard in Amplitude…" footer ran for up to 90 seconds with no other progress signal — users read it as a hung wizard. Screenshots: tasks visibly done + static footer = "looks broken or done."

This PR makes the post-agent gap visibly accounted for and quietly fixes a related silent-failure bug.

Visibility (the user-facing fix)

  • FinalizingPanel below the agent task list. Renders the post-agent step queue (commit events, create dashboard) with their own status icons ( pending → in_progress → completed | skipped + reason). Per-step elapsed time renders next to the active row. Coaching messages at 20s / 40s anchored to the active step's startedAt — not run elapsed (would have fired immediately after a 5-min agent run otherwise).
  • Footer status driven from the active step's activeForm — single source of truth, the visible task and the spinner footer can no longer disagree.
  • Generic ordered queue (postAgentSteps: PostAgentStep[]) rather than two named fields, so a future post-agent step drops in cleanly.
  • Each step owns its own lifecycle calls (commitPlannedEventsStep / createDashboardStepsetPostAgentStep). agent-runner just seeds + invokes.

Reliability

  • DASHBOARD_TIMEOUT_MS lowered 90s → 60s with a TODO comment explaining the deferred direct MCP path follow-up (real chart-tool schema discovery is needed first; the agent fallback stays as the only path for now).
  • commitPlannedEventsStep no longer silently drops planned events on appId-resolution failure: single retry of fetchAmplitudeUser (covers the common stale-OAuth-token race), then analytics.wizardCapture('planned events skipped', { reason }) + ui.log.warn(...) + the synthetic task ticks skipped with a visible reason.
  • AgentUI emits progress: post_agent_seeded and progress: post_agent_step NDJSON events so orchestrators see the same state.

Architecture notes

  • Post-agent steps live in their own queue, not in the agent's tasks array — keeps syncTodos's "agent-authoritative" invariant intact.
  • Stepper stays on Setup through post-agent work — Verify implies "your turn" and would mislead.
  • Mirrored PostAgentStepStatus in session-constants.ts per the existing ESM/CJS workaround; the session-constants-sync test enforces it.

Test plan

  • pnpm test — 2548/2548 unit tests, 14 skipped (existing). 12 new assertions for store setters, FinalizingPanel rendering states, and step-id constant sync.
  • pnpm test:bdd — 97/97 scenarios.
  • pnpm build — clean.
  • pnpm lint — clean.
  • Live pnpm try against a Next.js project — confirm: agent finishes → FinalizingPanel appears with "Save event plan" + "Create dashboard" rows ticking pending → in_progress → ✓ before the wizard transitions to Mcp screen; footer message matches the active step's activeForm.
  • Force the appId-resolution failure (e.g. block fetchAmplitudeUser via a network shim) — confirm synthetic task ticks to skipped with reason, analytics breadcrumb fires, wizard advances.

Out of scope (follow-ups)

  • Wiring a deterministic direct MCP path for chart/dashboard creation (create_chart × N → create_dashboard). Needs MCP write-tool schema discovery first — src/lib/planned-events.ts:100 shows the catch (create_events returns the literal "MCP error" sentinel today).
  • Splitting Screen.Run into separate Run / Verify screens — explicitly rejected (low value, high blast radius vs. the visibility fix).

🤖 Generated with Claude Code


Note

Medium Risk
Touches the main agent-runner post-run path and adds new session/UI state that affects multiple UIs, so regressions could impact completion flow or status reporting. Changes are additive with test coverage and mostly affect progress visibility and graceful-degradation paths.

Overview
Adds a framework-controlled post-agent step queue (postAgentSteps) that is seeded by agent-runner after the Claude agent completes and rendered in a new TUI FinalizingPanel, so users see progress (and per-step elapsed time/skip reasons) during the post-agent “silent gap”. The Run screen footer status now follows the active post-agent step’s activeForm, and Agent/CI UIs emit/log corresponding seedPostAgentSteps/setPostAgentStep updates (including NDJSON progress events).

Makes post-agent work more robust and observable: commitPlannedEventsStep now marks step lifecycle, retries fetchAmplitudeUser once when resolving appId, and emits analytics + a visible warning when planned-event commit is skipped; createDashboardStep similarly owns its lifecycle, shortens its timeout from 90s to 60s, and reports distinct skip reasons. Adds tests to lock down step-id constant sync, PostAgentStepStatus sync between canonical and TUI constants, store behavior for queue patching (startedAt, no-op on unknown ids), and FinalizingPanel rendering states.

Reviewed by Cursor Bugbot for commit fe440c9. Bugbot is set up for automated code reviews on this repo. Configure here.

After the Claude agent finished, the TUI sat on Screen.Run with all
agent task checkmarks ✓ while a static "Creating charts and dashboard
in Amplitude…" footer ran for up to 90 seconds with no other progress
signal — users read it as a hung wizard.

- Add a FinalizingPanel below the agent task list rendering the
  post-agent step queue (commit events, create dashboard) with
  per-step elapsed time and 20s/40s coaching anchored to the active
  step's startedAt — not run elapsed.
- Drive the footer status from the active step's activeForm so the
  visible task and the spinner footer can never disagree (single
  source of truth).
- commitPlannedEventsStep and createDashboardStep each own their own
  setPostAgentStep lifecycle calls (in_progress → completed | skipped
  with reason). Skipped rows surface the reason inline.
- commitPlannedEventsStep: single retry of fetchAmplitudeUser before
  giving up on appId resolution (covers the stale-OAuth-token race).
  Failure now emits analytics.wizardCapture('planned events skipped')
  + ui.log.warn — no more silent drops.
- DASHBOARD_TIMEOUT_MS 90s → 60s; TODO comment for the deferred
  direct-MCP-path follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kelsonpw kelsonpw requested a review from a team as a code owner April 30, 2026 05:36
@github-actions
Copy link
Copy Markdown
Contributor

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci django
  • /wizard-ci fastapi
  • /wizard-ci flask
  • /wizard-ci javascript-node
  • /wizard-ci javascript-web
  • /wizard-ci next-js
  • /wizard-ci python
  • /wizard-ci react-router
  • /wizard-ci vue

Test an individual app:

  • /wizard-ci django/django3-saas
  • /wizard-ci fastapi/fastapi3-ai-saas
  • /wizard-ci flask/flask3-social-media
Show more apps
  • /wizard-ci javascript-node/express-todo
  • /wizard-ci javascript-node/fastify-blog
  • /wizard-ci javascript-node/hono-links
  • /wizard-ci javascript-node/koa-notes
  • /wizard-ci javascript-node/native-http-contacts
  • /wizard-ci javascript-web/saas-dashboard
  • /wizard-ci next-js/15-app-router-saas
  • /wizard-ci next-js/15-app-router-todo
  • /wizard-ci next-js/15-pages-router-saas
  • /wizard-ci next-js/15-pages-router-todo
  • /wizard-ci python/meeting-summarizer
  • /wizard-ci react-router/react-router-v7-project
  • /wizard-ci react-router/rrv7-starter
  • /wizard-ci react-router/saas-template
  • /wizard-ci react-router/shopper
  • /wizard-ci vue/movies

Results will be posted here when complete.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Outer catch leaves create-dashboard step stuck at in_progress
    • Added getUI().setPostAgentStep(POST_AGENT_STEP_CREATE_DASHBOARD, { status: 'skipped', reason: 'unexpected error' }) in the agent-runner catch block so the step always reaches a terminal state.
  • ✅ Fixed: FinalizingPanel interval fires unnecessarily when steps empty
    • Made the setInterval conditional on hasActive (derived from steps having an in-progress entry) so the interval only runs when needed and the component avoids unnecessary re-renders.

Create PR

Or push these changes by commenting:

@cursor push 6b3c8fa00e
Preview (6b3c8fa00e)
diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts
--- a/src/lib/agent-runner.ts
+++ b/src/lib/agent-runner.ts
@@ -1188,6 +1188,10 @@
         err instanceof Error ? err.message : String(err)
       }`,
     );
+    getUI().setPostAgentStep(POST_AGENT_STEP_CREATE_DASHBOARD, {
+      status: 'skipped',
+      reason: 'unexpected error',
+    });
   }
 
   // MCP installation is handled by McpScreen — no prompt here

diff --git a/src/ui/tui/components/FinalizingPanel.tsx b/src/ui/tui/components/FinalizingPanel.tsx
--- a/src/ui/tui/components/FinalizingPanel.tsx
+++ b/src/ui/tui/components/FinalizingPanel.tsx
@@ -41,13 +41,18 @@
 }
 
 export const FinalizingPanel = ({ steps }: FinalizingPanelProps) => {
-  // Tick once per second so the active-step elapsed time and coaching
-  // tier transitions render — the panel is otherwise stateless.
+  const hasActive = steps.some(
+    (s) => s.status === PostAgentStepStatus.InProgress,
+  );
+
+  // Tick periodically so the active-step elapsed time and coaching
+  // tier transitions render — only while a step is in progress.
   const [, setTick] = useState(0);
   useEffect(() => {
+    if (!hasActive) return;
     const id = setInterval(() => setTick((t) => t + 1), SPINNER_INTERVAL);
     return () => clearInterval(id);
-  }, []);
+  }, [hasActive]);
 
   if (steps.length === 0) return null;

You can send follow-ups to the cloud agent here.

Comment thread src/lib/agent-runner.ts
Comment thread src/ui/tui/components/FinalizingPanel.tsx
@kelsonpw
Copy link
Copy Markdown
Collaborator Author

@cursor push 6b3c8fa

@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented Apr 30, 2026

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Refactored import outside try/catch breaks "never throws" contract
    • Moved the await import('./api.js') from outside the retry loop's try/catch to inside it, restoring the defensive error handling that prevents unhandled exceptions from propagating.

Create PR

Or push these changes by commenting:

@cursor push 151cf6cfc7
Preview (151cf6cfc7)
diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts
--- a/src/lib/agent-runner.ts
+++ b/src/lib/agent-runner.ts
@@ -330,9 +330,8 @@
   label: string,
 ): Promise<string> {
   try {
-    const { getStoredToken, getStoredUser, storeToken } = await import(
-      '../utils/ampli-settings.js'
-    );
+    const { getStoredToken, getStoredUser, storeToken } =
+      await import('../utils/ampli-settings.js');
     const { refreshAccessToken } = await import('../utils/oauth.js');
     const { EXPIRY_BUFFER_MS } = await import('../utils/token-refresh.js');
     const user = getStoredUser();
@@ -458,9 +457,8 @@
   // process.exit. The success-path re-fires below catch the two failure
   // modes that bypass wizardAbort (non-throwing return false, raw
   // throw).
-  const { registerCleanup, registerPriorityCleanup } = await import(
-    '../utils/wizard-abort.js'
-  );
+  const { registerCleanup, registerPriorityCleanup } =
+    await import('../utils/wizard-abort.js');
   registerPriorityCleanup(() =>
     restoreSetupReportIfMissing(session.installDir),
   );
@@ -721,9 +719,8 @@
   // skills are constants; the integration skill is resolved per framework
   // (with a sensible default fallback). When a skill is pre-staged we drop
   // the corresponding load_skill_menu / install_skill steps from the prompt.
-  const { preStageSkills, bundledSkillExists } = await import(
-    './wizard-tools.js'
-  );
+  const { preStageSkills, bundledSkillExists } =
+    await import('./wizard-tools.js');
   const integrationSkillId = config.metadata.getIntegrationSkillId
     ? config.metadata.getIntegrationSkillId(frameworkContext)
     : (() => {
@@ -1092,9 +1089,8 @@
   // Upload environment variables to hosting providers (auto-accept)
   let uploadedEnvVars: string[] = [];
   if (config.environment.uploadToHosting) {
-    const { uploadEnvironmentVariablesStep } = await import(
-      '../steps/index.js'
-    );
+    const { uploadEnvironmentVariablesStep } =
+      await import('../steps/index.js');
     uploadedEnvVars = await uploadEnvironmentVariablesStep(envVars, {
       integration: config.metadata.integration,
       session,
@@ -1174,9 +1170,8 @@
   // Amplitude MCP response can't hang the whole run. Gracefully degrades:
   // agent success is not affected by dashboard-step failure.
   try {
-    const { createDashboardStep } = await import(
-      '../steps/create-dashboard.js'
-    );
+    const { createDashboardStep } =
+      await import('../steps/create-dashboard.js');
     await createDashboardStep({
       session,
       accessToken,
@@ -1251,9 +1246,8 @@
   accessToken: string,
   cloudRegion: string,
 ): Promise<void> {
-  const { fetchHasAnyEventsMcp, fetchAmplitudeUser } = await import(
-    '../lib/api.js'
-  );
+  const { fetchHasAnyEventsMcp, fetchAmplitudeUser } =
+    await import('../lib/api.js');
   const { logToFile } = await import('../utils/debug.js');
 
   const POLL_INTERVAL_MS = 30_000;
@@ -1697,9 +1691,9 @@
   accessToken: string,
   cloudRegion: string,
 ): Promise<string | null> {
-  const { fetchAmplitudeUser } = await import('./api.js');
   for (let attempt = 1; attempt <= 2; attempt++) {
     try {
+      const { fetchAmplitudeUser } = await import('./api.js');
       const userInfo = await fetchAmplitudeUser(
         accessToken,
         cloudRegion as 'us' | 'eu',

You can send follow-ups to the cloud agent here.

Comment thread src/lib/agent-runner.ts Outdated
@kelsonpw
Copy link
Copy Markdown
Collaborator Author

@cursor push 151cf6c

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Existing dashboard path never marks step completed
    • Added the missing ui.setPostAgentStep(STEP_ID, { status: 'completed' }) call before the early return in the existing-dashboard path, so the step correctly transitions from in_progress to completed.

Create PR

Or push these changes by commenting:

@cursor push ff9d4a6206
Preview (ff9d4a6206)
diff --git a/src/steps/create-dashboard.ts b/src/steps/create-dashboard.ts
--- a/src/steps/create-dashboard.ts
+++ b/src/steps/create-dashboard.ts
@@ -126,6 +126,7 @@
       'duration ms': 0,
       source: 'agent',
     });
+    ui.setPostAgentStep(STEP_ID, { status: 'completed' });
     return;
   }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 407795e. Configure here.

Comment thread src/steps/create-dashboard.ts
@kelsonpw
Copy link
Copy Markdown
Collaborator Author

@cursor push ff9d4a6

When an existing dashboard file is found (the agent already created it),
the function returned after setting the step to 'in_progress' without ever
transitioning to 'completed'. This left the FinalizingPanel showing the
create-dashboard row permanently stuck at in_progress with a running timer.

Add the missing ui.setPostAgentStep(STEP_ID, { status: 'completed' }) call
before the early return, matching the pattern used by all other exit paths.

Applied via @cursor push command
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants