Skip to content

feat(wizard): surface the agent's pending question in the sync widget - #74324

Merged
trunk-io[bot] merged 7 commits into
masterfrom
fcgomes/wizard-sync-pending-input
Jul 29, 2026
Merged

trunk-io[bot] merged 7 commits into
masterfrom
fcgomes/wizard-sync-pending-input

Conversation

@fercgomes

@fercgomes fercgomes commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

While a local wizard run is blocked on a wizard_ask question, the sync widget in the web app just says the run is in progress. A user who tabbed away from the terminal has no way to know the agent is waiting on them, and after 5 minutes the ask times out and the run degrades.

Closes #74309 (companion CLI PR: PostHog/wizard#1008).

Changes

The wizard CLI now publishes the open question on the session upsert as a nullable pending_input field. Because the wire is a full-state upsert, dismissal needs no event of its own. The next push without the field clears the state, so "attention required" and "attention dismissed" are one mechanism.

Backend

  • WizardSession.pending_input nullable JSONField (additive migration), carried through the upsert path, DTO, serializer, and SSE payload unchanged.

Frontend

  • InstallationProgress gains pendingInput, extracted by pendingInputFromSession and gated on a live, non-stale session. A dead wizard never sends the clearing push, so the server's is_stale verdict is what retires an orphaned prompt.
  • The sync card leads with the call to action in the warning tone (IconQuestion, text-warning): "Your terminal needs your attention". What unblocks the run is knowing the terminal wants you, not what it asked, so the question itself sits below it in tertiary text. Sensitive asks (secrets) publish no prompt text by design, so the call to action stands alone. The prompt is marked ph-no-capture, since it is whatever the wizard chose to ask.
  • An open question overrides the widget's minimized state. The user who tucked the widget away mid-run is exactly the one who will miss the prompt. When the field clears, their minimize choice is restored, untouched.
  • New Storybook stories LocalWaitingForInput and LocalWaitingForSensitiveInput for the attention state, with and without prompt text.

Screenshots

The card blocked on a question. The call to action leads; the question is tertiary. A sensitive ask publishes no prompt, so the call to action stands alone.

Waiting for input Waiting for a secret
local-waiting-for-input local-waiting-for-sensitive-input

How did you test this code?

Automated only, no manual run of the full loop:

  • products/wizard/backend/tests/test_logic.py including a new round-trip test: pending_input stored on upsert, cleared by the next push without it (24 passed).
  • frontend/src/scenes/onboarding/shared/wizard-sync/ Jest suites, with new cases: surfacing on a live session, gating off for stale/terminal/absent rows, prompt withholding for sensitive asks, clear-on-next-push (208 passed).
  • helpers.test.ts pins the copy hierarchy: the prominent line is the call to action and never the question text, the question is carried separately so it can render below it, and a sensitive ask has no question to show.
  • test_presentation.py covers the serializer: sensitive prompts are dropped before the row is written, malformed pending_input is rejected (non-string prompt, over-long prompt, too many prompts, missing id, out-of-range count), and asked_at survives the latest, retrieve, and list read paths (77 passed).
  • Frontend typecheck introduces no new errors over master's baseline.

The new tests catch regressions no existing test did: the staleness gate (a dead wizard's prompt outliving it) and sensitive-prompt redaction.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

Not needed, feature is behind the existing wizard sync flags.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with Claude Code. Skills consulted: django-migrations, improving-drf-endpoints, writing-user-facing-copy, writing-tests conventions. Design decision worth noting: a fifth run_phase value (awaiting_input) was rejected because that enum is load-bearing across the model choices, generated API clients, and every phase branch in the frontend, and a session awaiting input is still running. A nullable side-field only touches consumers that care.

🤖 Generated with Claude Code

While a local wizard run is blocked on a wizard_ask question, a user
who navigated away to the web app sees a widget that just says the run
is in progress. If they don't return to the terminal, the ask times out
and the run degrades.

The wizard CLI now publishes the open question on the session upsert as
a nullable pending_input field (PostHog/wizard PR pending). Since the
wire is a full-state upsert, dismissal needs no event of its own: the
next push without the field clears the state.

Backend: nullable pending_input JSONField on WizardSession, carried
through the upsert path, the DTO, and the SSE payload unchanged.

Frontend: InstallationProgress gains pendingInput, gated on a live
non-stale session so a dead wizard's prompt cannot outlive it. The sync
card and dialog render the question in the warning tone, with a generic
terminal pointer for sensitive asks (their prompt text never leaves the
CLI). An open question also overrides the widget's minimized state, and
the server clearing the field restores the user's choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Bundle size — 🔺 +25.9 KiB (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 65.22 MiB · 🔺 +25.9 KiB (+0.0%)

File Size Δ vs base
render-query/src/render-query/render-query.js 21.25 MiB 🟢 -36.3 KiB (-0.2%)
posthog-app/src/lib/components/Shortcuts/utils/DebugCHQueriesImpl.js 33.4 KiB 🔺 +18.2 KiB (+119.1%)
posthog-app/_parent/products/data_catalog/frontend/DataCatalogMetricScene.js 16.1 KiB 🔺 +16.1 KiB (new)
posthog-app/_parent/products/mcp_analytics/frontend/MCPAnalyticsScene.js 156.6 KiB 🔺 +16.1 KiB (+11.4%)
posthog-app/_parent/products/data_catalog/frontend/DataCatalogScene.js 11.7 KiB 🟢 -5.1 KiB (-30.5%)
exporter/src/exporter/scenes/ExporterQueryScene.js 3.7 KiB 🔺 +3.7 KiB (new)
posthog-app/_parent/products/managed_migrations/frontend/ManagedMigration.js 27.4 KiB 🔺 +3.3 KiB (+13.6%)
posthog-app/src/scenes/insights/InsightScene.js 45.5 KiB 🔺 +3.1 KiB (+7.3%)
posthog-app/_parent/products/workflows/frontend/WorkflowsScene.js 77.1 KiB 🔺 +1.2 KiB (+1.6%)
exporter/src/exporter/scenes/ExporterNotebookScene.js 3.11 MiB 🔺 +1.2 KiB (+0.0%)

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

✅ Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.24 MiB · 22 files no change ███░░░░░░░ 27.6% of 4.51 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.10 MiB · 3,017 files 🔺 +9.6 KiB (+0.1%) ████████░░ 83.4% of 9.71 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
281.6 KiB ../node_modules/.pnpm/posthog-js@1.407.6/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
235.5 KiB src/taxonomy/core-filter-definitions-by-group.json
226.5 KiB ../node_modules/.pnpm/posthog-js@1.407.6/node_modules/posthog-js/dist/module.js
154.3 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
105.2 KiB src/lib/api.ts
94.0 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

✅ Toolbar bundle — eager 2.18 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.18 MiB · 17 files 🔺 +55 B (+0.0%) ████░░░░░░ 38.2% of 5.72 MiB
Deferred (lazy) 2.07 MiB · 33 files no change n/a — loads on demand
Loader dist/toolbar.js 1.1 KiB no change █░░░░░░░░░ 5.8% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
717.2 KiB dist/toolbar/toolbar-app-JFACJRNE.css
546.0 KiB dist/toolbar/chunk-chunk-CDYMM3XO.js
484.3 KiB dist/toolbar/chunk-chunk-CQF6YMXB.js
133.6 KiB dist/toolbar/chunk-chunk-XSXSV5RW.js
131.8 KiB dist/toolbar/chunk-chunk-T5KY5WYR.js
71.0 KiB dist/toolbar/toolbar-app-UEUIIM6N.js
69.0 KiB dist/toolbar/chunk-chunk-27JL52RE.js
35.6 KiB dist/toolbar/chunk-chunk-IXOMC3I3.js
20.9 KiB dist/toolbar/chunk-chunk-ZBTP6T3F.js
12.2 KiB dist/toolbar/chunk-chunk-PIK3PADE.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

✅ Dist folder size — 🔺 +4.72 MiB (+0.3%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1367.87 MiB · 🔺 +4.72 MiB (+0.3%)

✅ Playwright — all passed

All tests passed.

View test results →

⚠️ Django migration SQL — 1 new migration to review

We've detected new migrations on this PR. Review the SQL output for each migration:

products/wizard/backend/migrations/0003_wizardsession_pending_input.py

BEGIN;
--
-- Add field pending_input to wizardsession
--
ALTER TABLE "wizard_wizardsession" ADD COLUMN "pending_input" jsonb NULL;
COMMIT;

Last updated: 2026-07-29 14:19 UTC (a3a8070)

✅ Django migration risk — migration analysis complete

We've analyzed your migrations for potential risks.

Summary: 1 Safe | 0 Needs Review | 0 Blocked

✅ Safe

Brief or no lock, backwards compatible

wizard.0003_wizardsession_pending_input
  └─ #1 ✅ AddField
     Adding nullable field requires brief lock
     model: wizardsession, field: pending_input

📚 How to Deploy These Changes Safely

AddField:

This operation acquires a brief lock but doesn't rewrite the table.

Deployment uses lock timeouts with automatic retries, so lock contention will cause retries rather than connection pile-up.

Last updated: 2026-07-29 14:20 UTC (a3a8070)

@fercgomes
fercgomes marked this pull request as ready for review July 28, 2026 18:58
@fercgomes
fercgomes requested review from a team, MattBro and rafaeelaudibert and removed request for a team July 28, 2026 18:58
@github-actions

github-actions Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

🦔 Hogbox preview · ❌ build failed

The preview didn't come up for commit a3a8070. See the build log for the failing step. It'll retry on the next push.

Previews are optional and never block merging. A failure here is often a hogland or tailnet hiccup rather than anything in your PR, so the check stays green and this comment is the status.

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team July 28, 2026 18:59
@veria-ai

veria-ai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Security Review

Sensitive prompt text is only redacted in the browser; a payload marked sensitive can still be persisted, streamed, and logged when it includes prompts.

Prompt To Fix All With AI
### Issue 1
products/wizard/backend/presentation/views.py:289
**Sensitive prompts persist unredacted**

When a wizard client submits `sensitive: true` with a nonempty `prompts` field, the backend forwards, persists, serializes, and publishes that text unchanged; browser-side filtering occurs only after the sensitive content has reached storage, SSE clients, and pubsub logging. Enforce redaction before persistence so the sensitive-question contract does not depend on every producer behaving correctly.

**How this was verified:** The request-controlled object was traced unchanged through the view, session upsert, DTO serialization, and pubsub/SSE path, with prompt removal occurring only in the frontend.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(wizard): surface the agent's pendin..." | Re-trigger Greptile

@posthog

posthog Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

✅ Visual changes approved by @fercgomes — baseline updated in a3a8070.

View this run in PostHog

2 changed, 2 new.

@trunk-io

trunk-io Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

Comment on lines 28 to +34
tasks = models.JSONField(default=list)
event_plan = models.JSONField(null=True, blank=True)
error = models.JSONField(null=True, blank=True)
# An in-flight wizard_ask prompt ({id, asked_at, question_count, sensitive, prompts?}).
# Null means no input is pending — each push replaces it, so the CLI clearing the
# question is just the next upsert without the field.
pending_input = models.JSONField(null=True, blank=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: I wonder if we should enforce some kind of schema to these eventually?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Worth noting this PR ended up doing exactly that for pending_input, after the security bots flagged the free-form dict. It now goes through a typed PendingInputSerializer that bounds the id, timestamp, question count, and prompt list, and drops prompts server-side when sensitive is set, so a malformed or oversized payload is a 400 rather than something the app-wide widget has to survive rendering. That also means the generated frontend type is a real PendingInputApi instead of an unchecked cast.

tasks, event_plan, and error are still free-form. Leaving them as they are here to keep this PR to the one field, but the pattern is in place if we want to extend it, and error looks like the next obvious candidate since it already has an implied {type, message} shape. Happy to follow up separately if you think it is worth doing.

2 updated
Run: a51c28d8-6bf3-439d-88c9-461ea0f08e48

Co-authored-by: fercgomes <28491014+fercgomes@users.noreply.github.com>
pending_input was accepted as a free-form dict, so two things followed from trusting the producer: the sensitive contract was enforced only in the browser, after the prompt text had already been persisted, streamed over SSE, and logged; and a non-string prompt would reach the app-wide widget and throw on render.

Give the field a typed serializer that bounds the id, timestamp, count, and prompt list, and drop prompts server-side when sensitive is set, before the row is written. The frontend keeps a runtime string filter, since the generated type is the serializer's promise rather than a guarantee about rows already stored.

Also fixes the test_stream helper, which still omitted the now-required pending_input argument.
pending_input is a JSONField, so mypy types it as Any | None and rejected both the membership check and the subscript.
@github-actions
github-actions Bot requested a deployment to preview-pr-74324 July 29, 2026 12:02 In progress
asked_at is validated as a datetime but stored as a string, since the column is JSON. Pin that every read serializes it back out cleanly, so a later change to the field type cannot quietly break latest, retrieve, and list.
The card led with the question text, which reads as information rather than as something to act on. What unblocks the run is knowing the terminal wants you, not what it asked.

The prominent line is now that call to action, and the question moves below it in tertiary text. Sensitive asks publish no prompt, so the call to action stands alone. The prompt is marked ph-no-capture, since it is whatever the wizard chose to ask.
4 updated
Run: 7d0e6c1e-c2a5-45d0-b655-874956f9e910

Co-authored-by: fercgomes <28491014+fercgomes@users.noreply.github.com>
@fercgomes

Copy link
Copy Markdown
Contributor Author

/trunk merge

1 similar comment
@fercgomes

Copy link
Copy Markdown
Contributor Author

/trunk merge

@trunk-io
trunk-io Bot merged commit c28bb08 into master Jul 29, 2026
231 checks passed
@trunk-io
trunk-io Bot deleted the fcgomes/wizard-sync-pending-input branch July 29, 2026 15:51
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-29 16:39 UTC Run
prod-us ✅ Deployed 2026-07-29 16:58 UTC Run
prod-eu ✅ Deployed 2026-07-29 16:58 UTC Run

This branch had an error being deployed

1 failed deployment
preview-pr-74324 — a3a8070f Deployed Jul 29, 2026 by github-actions[bot]
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.

Allow the Wizard to send a "user input required" event

2 participants