mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 04:41:59 -10:00
dev
76
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
25e1ea300c |
feat(platform): Continue with Microsoft, with the Microsoft 365 offer as a movable onboarding step (#16269)
> Builds on #16268, which merged into `dev` on 2026-10-30. This PR now
targets `dev`, and its diff is only its own changes.
### Why / What / How
**Why:** people whose work runs on Microsoft 365 had no way to sign in
with Microsoft, and connecting their mail and files came later as a
separate step most never found. The onboarding wizard also had a fixed
order, so trying the connection offer earlier or later meant a code
change.
**What:**
- **Continue with Microsoft** on the login and signup pages. It asks
Microsoft only for name and email (`openid profile email`, no Graph
permission).
- A new account's onboarding asks **"Also connect Microsoft 365?"**
using the panel from #16268. Skipping it is fine.
- The wizard's order is data, so an experiment can move the step without
a code change.
**How:**
- **Who signs in** (`lib/auth/microsoft-login.ts`,
`microsoft-sign-up-policy.ts`): Better Auth keys accounts on Microsoft's
object ID (`oid`), never the email address. An address counts only when
Microsoft vouches for it: a personal account's own Outlook.com, Hotmail,
Live or MSN mailbox, or a work address its organization verified
(`xms_edov`). A personal Microsoft account can carry any address and
keeps it after its owner loses the mailbox, and a work address without
`xms_edov` is whatever an admin typed in. A guest's address is never
trusted either: a personal account invited into someone else's
organization signs in with that organization's tenant, whose `xms_edov`
would vouch for it. A token counts as a member's only when its `idp` is
absent or exactly the issuing tenant's issuer. Any other address neither
joins an existing account nor creates one. Both answer Better Auth's
`account_not_linked`, the same redirect to the byte, so a refusal
doesn't reveal whether an AutoGPT account uses the address. /login gives
one message for both and points to email sign-up. Emailing a
confirmation link would not be safe: the mailbox's owner could confirm
an account that the other person's Microsoft login still opens.
- **Team addresses** (`microsoft-sign-up-policy.ts`): an AutoGPT team
address (`@agpt.co`) gets an account only through Google, however
Microsoft vouches for it. A refused one lands on
`/login?provider=microsoft&error=team_email_requires_google` with its
own message. A team member's existing account still signs in with
Microsoft. Microsoft's checks run first in the user-create hook, so its
sign-ups get these codes. #16445 applies the team-address and
verified-email rules to every provider; whichever of the two PRs merges
second keeps that hook order (Microsoft's check, then the team-address
check, then the verified-email check), and a trial merge of both passes
their sign-up tests together.
- **Only the redirect flow signs in**: Better Auth's ID-token sign-in
(`POST /sign-in/social` with `idToken`) is switched off for Microsoft,
because it creates accounts without the callback's checks; the sign-up
check also follows the provider rather than the callback path.
- **Refusals**: a Microsoft sign-up the signup gate refuses shows the
"Email Not Allowed" dialog on /login. Better Auth sends a refusal from
our checks back with its code as `error` (`SIGN_UP_NOT_ALLOWED` for the
gate, which `isWaitlistSignInError` reads). A blocked network, a
disposable address or one mail can't reach say the sign-up isn't allowed
instead of "try again", and a blocked sign-in says so too. The page
never shows the URL's own text. A refusal keeps the page's `next`, so a
deep link still opens after logging in another way. Since #15459 every
provider's refusal comes back to /login; Microsoft's also says
`provider=microsoft`, and only then does /login use the Microsoft
wording.
- **The access step** (`onboarding/steps/AccessStep/`): it shows when
the account signed in with a provider whose data Otto can use.
`accessProviders.tsx` maps each provider to its connection, so Google or
GitHub can join with one entry. `GET /api/auth/sign-in-providers` names
the account's sign-in providers, looked up once per account; Better
Auth's own `/list-accounts` stays disabled because it returns account
details.
- **Movable order** (`onboarding/store.ts`): `DEFAULT_STEP_ORDER` lists
the steps. The `onboarding-step-order` flag's payload names the steps to
lead with; the rest keep their default order, and `preparing` stays
last. With payments on, the access step always follows the subscription
step, whatever the experiment asks: a new account has no plan until
then, and connecting a service needs one. The order is frozen per
account when the wizard loads, through `useFrozenPerUser`, which the
payment snapshot now uses too (dev's ref read during render raised 14
`react-hooks/refs` warnings). Progress is saved by step name, so a
resumed wizard lands on the right step even if the order changed.
### Changes 🏗️
- Frontend auth: the Microsoft login provider (shown only when
`AUTH_MICROSOFT_CLIENT_ID`, `AUTH_MICROSOFT_CLIENT_SECRET` and
`NEXT_PUBLIC_AUTH_MICROSOFT_ENABLED` are set), the sign-up refusal for
unconfirmed addresses, error messages on /login, and Microsoft sign-ups
counted as such in analytics.
- Onboarding: the access step, `resolveStepOrder` (with the paywall
rule) and `buildStepLayout` with an `order`, `useFrozenPerUser`, and
`access` as a saved step name (backend `WizardStep`).
- Backend: the `onboarding-step-order` payload flag, and a seeded e2e
user who signed up with Microsoft.
- From CodeRabbit's and Sentry's reviews, once this targeted `dev`:
- Login and signup start one provider sign-in at a time.
- The sign-in-provider lookup gives up 5 seconds after it starts, its
retry included, so it holds onboarding for at most 5 seconds. A quick
failure gets one retry in what's left of that time; a lookup that timed
out isn't retried.
- A saved draft keeps the role pick once the person moves past the role
step, in whatever order the wizard used. It reads that order from
`completedSteps`. With an experiment that asks the role first, drafts
saved on the steps after it used to leave the pick out until
`painPoints`.
- A regression from #16268: the Copilot executor again reports a refused
Microsoft refresh as `microsoft_365_copilot_credential_unavailable`, not
`…_not_found`. Since #16268 the refused refresh raised
`MicrosoftAuthError`, a `ValueError` subclass.
- With Better Auth 1.7.12 (#16419) merged from `dev`:
- The Microsoft refusals carry codes (`account_not_linked`,
`team_email_requires_google`). 1.7's OAuth callback only redirects an
error that has one; any other ends on a raw 403 JSON page.
- /login explains the codes dev's own sign-up checks now send.
- Accounts are keyed on `oid`, as 1.7 does, instead of `${tid}:${oid}`.
Dev and prod have no Microsoft login rows yet, so nothing needs
migrating.
- The Playwright spec signs in through the API. The login form can be
replaced just after it loads, which dropped the email the spec typed
(smoke failed once on CI).
- From Pwuts's review:
- An address Microsoft didn't confirm gets `account_not_linked` with no
message, as described above.
- Sign-in no longer fetches the Microsoft profile photo, so it reads
only name and email.
- The Outlook.com domains are the 113 whose primary MX is Outlook.com's
consumer servers. That adds `hotmail.se`, `live.se` and the other
country domains, and drops `outlook.co.uk` (its mail goes to a third
party) and `hotmail.nl`, `hotmail.cz` and `outlook.cz` (a Microsoft 365
organization first).
- Both sign-in buttons' logos are decorative (`alt=""`), so a screen
reader says each provider once.
- `openapi.json` regenerated; `.secrets.baseline` line numbers updated.
- `docs/engineering/microsoft-local-e2e.md`: sign-in, the access step,
moving it, and the extra Entra settings.
**Deploying:** this builds on #16268, whose Entra step 1 (token version
2, then personal accounts in the sign-in audience) has to happen before
this release reaches an environment. The Entra app also needs the
`{frontend}/api/auth/callback/microsoft` redirect and the `xms_edov`
optional ID-token claim. Without the claim, work and school accounts
can't use Continue with Microsoft. Dev is configured; prod is not.
### Agents and large language models used
- Claude Code with Claude Opus 5.5: design, implementation, tests and
live testing.
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `lib/auth/__tests__/microsoft-sign-in-flow.test.ts` runs Better
Auth's real handler with Microsoft's endpoints stubbed: an Outlook.com
personal account signs up; a verified work address joins the account
that uses it; a personal account's other address, an unverified work
address and a guest's address are kept out of it; an unconfirmed address
creates nothing, with email verification on or off; an ID token for an
Outlook.com address, signed with a test key served as Microsoft's key
set, can't sign in directly; and a waitlist refusal comes back as a code
/login recognizes. Before the fixes, the guest case joined the existing
account and the ID token created one; with `disableIdTokenSignIn`
removed, the ID token still signs in.
- [x] `vitest` over onboarding, login, signup, `lib/auth`, the Microsoft
panel and feature flags: 935 passed, including the access step offering
only "Continue" once connected and following the paywall when payments
are on. `pnpm types` and `pnpm lint` pass.
- [x] Backend: `pytest backend/data/onboarding_wizard_test.py
backend/util/feature_flag test/test_e2e_seed_credentials.py`: 284
passed.
- [x] After #16268 merged, with `dev` merged in (only the shared
engineering doc conflicted; this PR's copy, which extends #16268's, was
kept): backend 674 passed (onboarding wizard, feature flags, Microsoft
integrations, Copilot transports), black, isort and pyright clean, and
`openapi.json` matches the export. Frontend `pnpm types` and `pnpm lint`
pass, and all four `vitest` shards pass under `TZ=UTC` (12,700 tests).
- [x] The team-address rule: `microsoft-sign-in-flow.test.ts` drives the
real callback for an `@agpt.co` work address (refused, nothing created)
and for a team member's existing account (signed in), and the login page
test shows the message.
- [x] Screenshots, before (dev-builder) and after (this branch) at 1440
and 390 px, of /login and /signup with the Microsoft button, /login
after a refused sign-in, and the access step:
https://github.com/Significant-Gravitas/AutoGPT/pull/16269#issuecomment-6560941949
- [x] Playwright `microsoft-sign-in-happy-path.spec.ts`: a seeded
Microsoft sign-up opens onboarding on the offer, and "Not now" moves on.
It passed locally against a full stack and in CI's smoke run on
|
||
|
|
30b70e7679 |
feat(backend): send a PostHog event whenever a usage limit refuses or pauses work (SECRT-3411) (#16698)
### Why / What / How **Why:** when a usage limit refuses or pauses someone's work, PostHog hears nothing, so we can't see how often limits bite, for whom, or where (SECRT-3411). **What:** one backend event, `chat_blocked`, every time a limit refuses, pauses or queues work, from every surface. The name and its `reason` values (`usage_limit`, `rate_limit`) come from the analytics plan; the event now also fires for scheduled, bot, block, voice, API and background refusals, not just the chat. **How:** the event is sent where each refusal is answered, not where the limit is read. `check_rate_limit()` is also used by probe-only checks (for example `at_reset`), and only the caller knows the surface. `RateLimitExceeded`, `TurnRefusal`, `ConcurrentTurnLimitError` and `InflightCapExceeded` now carry the reading (tier, used, cap), so each call site is one line. Sending goes through `posthog_client.capture` (queued on the client's background thread), and every helper is guarded end to end, reading the refusal included, so a refusal returns exactly what it returned before, just as fast, even when PostHog raises. Server events have no consent check, as for the other backend events. ### Changes 🏗️ - `backend/copilot/limit_tracking.py` (new): `track_chat_blocked` and helpers for cost limits, turn caps, turn pauses and queue/scheduler refusals; the surface of an executor turn is worked out from its origin. - Properties: `reason`, `limit` (`daily`, `weekly`, `trial`, `task`, `rate`, `concurrency`), `outcome` (`refused`, `paused`, `queued`), `surface` (`chat`, `schedule`, `chat_bot`, `block`, `voice`, `api`, `background`), `tier`, `used` and `cap` (dollars for cost limits, counts for rate/concurrency), `resets_at` (null for a trial, a window that allows nothing, and the turn caps). `distinct_id` is the user id. No message content or personal data. - Where it fires: - Chat: the stream route's cost check (`enforce_platform_allowance`, also used by Keep going), the concurrency 429s and the queue fallback (`queued`), the queued-message per-minute cap, and the Keep going concurrency 429. - Chat bots: the usage gate and the in-flight cap. - Voice (`/speech`), external API/MCP tool calls, and the nightly memory pass (weekly). - Scheduler: usage refusals and the turn cap, and "Send/Resume at reset" turns a used-up window holds back (`queued` to the next reset, `refused` when nothing refills) or the turn cap refuses. - Queue promotion: a queued turn a usage window holds back (`queued`), checked once per pass over the queue. - Executor: the pre-turn allowance gate (`refused`), plus every pause the engine streams: a landed `daily`/`weekly`/`trial`/`task` pause (`paused`) and a refused auto-continue (`refused`). A `steps` check-in is not a limit and sends nothing. - Sub-sessions and the AutoPilot block at the turn caps, approval wakes, `message_session` wakes, and per-call platform spend (copilot tools, dry-run blocks, the simulator). - Readings: a pause the engine streams keeps the reading its check made (on the stream part as a private attribute, never serialised to the client or the stored marker), and a turn that lands on its budget carries its plan tier. Used and cap stay null for a mid-turn landing. - Surface: checks deep inside a turn (a tool's platform spend) report the surface the executor resolved, so a chat bot's turn is `chat_bot` and unattended work `background` there too. - An ended trial reads as `trial` (cap 0) and a plan with no allowance as `weekly` (cap 0) at every site, through one helper. - A turn the executor refuses before it starts is marked `impersonated` when an admin started it. - Each backend process sends at most one `chat_blocked` a minute per user, limit, outcome and surface, so a client retrying a refused request sends one event, not one per retry. The window is in memory: it never blocks or fails the refusal. - The queued-turn event carries the queued turn's own impersonation flag, stored when it was queued, not that of the turn whose end ran the queue. - `PostHogEvent.CHAT_BLOCKED` added and pinned; `docs/engineering/tracking-plan.md` lists the event and drops it from "not sent yet". - A `chat_blocked_events` test fixture in `backend/conftest.py`, and tests at each refusal point that check the properties sent. Out of scope: `/usage/reset` 429s and the generic `api/utils/rate_limit.py` limiter for non-chat routes (not usage limits on work). Also left out: outages that leave the limits unreadable (503s), paywall/ban/Codex-access refusals (not usage limits), the engine-switch continuation dispatch and the AutoPilot block's recovery re-enqueue (best-effort system retries). Post-merge checks: - [ ] Build a PostHog insight on `chat_blocked`, broken down by `limit`, `surface` and `outcome`. - [ ] Compare one day of Sentry 429s on `POST /api/chat/sessions/{id}/stream` with `chat_blocked` where `surface = chat` and `limit` is `rate`, `concurrency` or a cost limit. ### Agents and large language models used - Claude Code, Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Unit tests for `limit_tracking` (properties, reason mapping, refill rules, surfaces, never raises, even on a refusal it can't read) - [x] A refusal answers the same when PostHog raises, at real call sites (MCP tool call, executor gate) - [x] Event assertions at each refusal point: chat route, Keep going, queued messages, chat bots, voice, MCP, dream, scheduler and at-reset turns, queue promotion, executor gate and streamed pauses, sub-sessions/AutoPilot block, approval wakes, `message_session`, platform spend - [x] Existing suites for every touched module pass locally (DB-backed tests left to CI) - [x] pyright, ruff, black and isort clean on the changed files 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
9d04ac1a8d |
feat(platform): self-serve account deletion with an optional exit question (#15459)
### Why / What / How **Why.** Today the only way to delete an AutoGPT account is to email [email protected], and people without a plan can't reach Settings at all (the paywall covers `/settings/*`). SECRT-2877 asks for self-serve deletion from Settings › Account and from the paywall, with an optional "Why are you leaving?" step. **What.** A two-step Delete account dialog: the optional exit question (each reason offers one alternative, such as Manage connected apps or cancelling a trial), then a confirmation where the person types their email. Confirming closes the account at once. The data is erased 14 days later; until then the owner can sign in and choose **Keep my account**. Erasing the data is #15487 (SECRT-2854), which also holds the ban record that outlives erasure. The `self-serve-account-deletion` flag stays off until that erasure runs in prod. **How.** `POST /api/account/deletion` (`account_deletion/service.py`): 1. Check the typed email (trimmed, any case), else 400. Already closed: 200, nothing else runs. 2. Stripe: expire open Checkouts, cancel every subscription now with no invoice or proration, turn off auto top-up. A Stripe error returns 502 and the account stays open. 3. Best effort: disconnect and revoke every connected app (10 at a time, 10 s each), revoke API keys and OAuth grants, remove schedules and the Briefing, turn off webhook triggers, unlink chat platforms, cancel AutoPilot turns, stop runs, turn off product email, opt out of MailerLite. 4. One transaction: `AccountDeletionRequest` with `eraseAfter` = now + 14 days, all sessions deleted, Marketplace listings soft-deleted, reviews deleted, share links off, Discord/Telegram/Slack links removed. 5. Email "Your AutoGPT account is closed" ("Sign in by {date} and choose Keep my account"), then one anonymous count. While closed, every API route answers 401 "This account is closed." (optional-auth routes treat it as signed out; WebSocket and external API refuse it). `is_user_refused` gates all background work: scheduler, memory crons, graph runs, AutoPilot, webhook ingress, Briefing. The closed state is cached 30 s and dropped on close and keep. The creator page is hidden. Closing does not ban; a banned person can still delete, but can't sign in to keep. Keeping: sign-in lands on `/account-closed` with **Keep my account** and **Sign out**. `POST /api/account/deletion/restore` deletes the request only while `startedAt` and `completedAt` are null and `eraseAfter` is ahead, in one statement (409 otherwise); #15487 claims a row by setting `startedAt` first. Only the account comes back: plan, apps, keys, schedules, triggers, share links, listings and reviews stay off, and product email stays off until switched on. Feedback (`POST /api/account/deletion/feedback`) goes to PostHog through the new `capture_anonymous()`: no user id, email or IP; text capped at 1,000 characters, never logged; 20 calls per user per hour. The dialog is `ph-no-capture`. ### Changes 🏗️ Backend - `api/features/account_deletion/`: routes, service, step modules, `restore.py`. - `data/account_deletion.py`: close, reopen, request record. - `autogpt_libs/auth/closed_accounts.py`, `dependencies.py`: closed check in every auth dependency; `get_user_id_allowing_closed`. - `data/user.py`: `is_user_refused`; no billing or marketing email to a closed account. - Refusal gates in `executor/`, `copilot/`, `integrations/router.py`, `api/ws_api.py`, `api/external/middleware.py`, `platform_linking/`. - `data/credit.py`, `data/subscription_checkout.py`, `notifications/lifecycle.py`: immediate cancel tagged `autogpt:account_deleted`, with no "ended" emails or MailerLite moves. - `integrations/router.py`: `delete_and_revoke_credentials` extracted, route unchanged. - `integrations/oauth/google.py`: revoke sends the refresh token (all Google disconnects). - `notifications/auth.py`: `account_deleted` email. - `api/features/store/`: hidden creator page, cache clear. - `data/auth/`: bulk revoke for API keys and OAuth. - `util/posthog_*`: `capture_anonymous()`, five events. Frontend - `components/organisms/DeleteAccountFlow/`: dialog, steps, panels, stories, tests. - `DeleteAccountCard` (Settings), `DeleteAccountButton` (paywall). - `app/(no-navbar)/account-closed/`: the keep page; `lib/auth/closed-account.ts` routes closed accounts there. - `QuestionOptionList`: `editable` prop; `Dialog`: opt-in drawer `autoFocus`. - `services/billing/subscription-plan.ts`: shared with `useYourPlanCard`. - `openapi.json`; `docs/engineering/tracking-plan.md`. **Configuration changes** - Flag `self-serve-account-deletion`, off by default (`FORCE_FLAG_` / `NEXT_PUBLIC_FORCE_FLAG_` overrides). Off: no entry points, delete and feedback 404. Status and restore stay reachable so nobody is stranded. - Migrations `20261010120000_account_deletion_request`, `20261030120000_account_deletion_grace_period`. - Routes: `POST`/`GET /api/account/deletion`, `POST .../restore`, `POST .../feedback`. Not behind the paywall; 403 for an admin impersonating (except status). - Protected page `/account-closed`. - Proxy: 180 s timeout for `POST api/account/deletion`. - CI: E2E forces the flag on; smoke count 28 → 29. - No new secrets or env vars. **Known gaps** - Cloudflare's ~100 s origin timeout is under the 180 s proxy timeout: many apps can show a gateway error; a retry returns 200. - Open Stripe invoices stay open, with collection stopped. - `opened` shares the 20/hour feedback cap with answers. - A schedule fire or webhook delivery already under way can still add one run or turn. - Deploy the DB manager with or before the chat bot (new `open_only` lookups). ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan: - [x] Backend, frontend and `autogpt_libs` tests pass in CI on the current head. - [x] Playwright: delete from Settings, sign in, `/account-closed`, Keep my account, back to Settings. - [x] Dialog at 320×568 to desktop, 200% and 400% zoom: no sideways scroll, footer reachable, focus never hidden. - [ ] Manual delete from Settings and paywall, then Keep, on the current head. - [ ] `/account-closed` at the same sizes; iOS Safari and Android Chrome. - [ ] Dev deploy with the flag on: plan cancelled with no invoice, Google grant gone, schedule gone, email arrives, PostHog events have no person, a double call sends one email. #### Screenshots [The screenshot page](https://claude.ai/artifact/M4jqDhdBQvgfCAdbEFExD7) has the dialog at all seven sizes, both entry points, and the keyboard-focus checks. #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) Resolves SECRT-2877 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Krzysztof Czerwinski <[email protected]> |
||
|
|
e6a3d35514 |
feat(platform/onboarding): Otto replies on the brain dump's not-enough screen (#16654)
### Why / What / How **Why:** A large share of onboarding brain dump recordings land on the "We didn't catch enough of that" screen, and most people there skip or leave instead of talking again. Rejected takes are tiny (mic checks, greetings, filler), and today's screen reads like an error: it never shows what was heard, and its headline sounds like the microphone failed. Context: [SECRT-3361](https://linear.app/autogpt/issue/SECRT-3361). **What:** The screen becomes a short, labeled chat ("Otto replies"): what Otto heard, his answer, and one big, easy question, "What do you want off your plate?", with an example for the role. Talk stays the main action, and talking or writing afterwards keeps the question in view. The backend now sends back what was heard on an `insufficient_content` reject, and known speech-to-text silence phrases become `no_usable_speech` for voice, so the screen never shows words nobody said. **How:** The rejection fills the existing `transcript_preview` field; the only API addition is the optional `otto_reply_kind` enum ( |
||
|
|
bba2ff341a |
feat(platform/onboarding): plan-first trial: starter tasks and experts on the paywall, hire step removed, team hired on trial start, Home starts with ranked tasks owned by experts (#16308)
### Why / What / How **Why.** Since Oct 16, 954 people signed up, 444 saw the paywall, 209 opened Stripe and 29 started a trial ([funnel](https://eu.posthog.com/project/117338/insights/ugcxrNQU)). 68% of the people who reached the hire step hired nobody, and only 3 people have ever clicked a suggested first task on Home. The hire step asked people to pick experts before they had seen what those experts would do, the paywall then asked for a card without a reason, and Home's suggestions had no owner and no order. **What.** The paywall becomes the plan: what Otto heard, the first three tasks ranked, each owned by the expert who will do it, beside the 7-day trial. The hire step is gone. The plan's team is hired on the preparing screen once the trial is confirmed, so a user who abandons Stripe hires nobody. Home opens on the same tasks as a list: "Start here" on the first open row, each row naming who takes it, x removes a task, and Hide / Show collapses the list. **How.** - **Backend.** After the greeting and the team are stored, one TypeSafe Jev call ranks the greeting's tasks and picks an owner for each (a call to TypeSafe's hosted model, which carries the tasks, the team and up to 6,000 characters of the transcript); a deterministic fallback keeps the greeting's order and matches owners by icon and title. The team is capped to the three people the step shows. `GET /onboarding/brain-dump/plan` serves the plan (or a role plan for a skipped, failed or empty brain dump), `POST /onboarding/brain-dump/plan/settle` makes the plan the step shows the take's plan when the step stops waiting, and `POST /onboarding/brain-dump/plan/hire` hires its team idempotently. A suggested task's `expert_id` is read fresh: an open task's from the owner's active hire, a started task's from the chat it opened. - **Onboarding.** `PlanTrialStep` (`PlanPanel` beside `TrialPanel`, or `PaidPanel` without a trial offer, or the plan alone with Continue when there is no paywall) replaces the hire step and the trial welcome. `usePlanHire` hires on the preparing screen after the trial (or plan) is confirmed. - **Home.** `OnboardingIntroCard` goes back to `SuggestedTaskList`; the #16236 cards are deleted. A task is sent to its owner the way the recipient picker sends to an expert, so its chat is the expert's. ### Progress - [x] **Phase 0**: branch, baseline counts, before screenshots. Frontend `vitest` 12,282 passed + 4 expected failures (1,127 files); backend `onboarding_dump` + `experts` 904 passed, 29 skipped. - [x] **Phase 1** `bd3dc3cc feat(backend/onboarding): plan with ranked, owned tasks and a hire endpoint` (+ `f879fb20` review follow-ups). - [x] **Phase 2** `ba232616 feat(frontend/onboarding): plan + trial step replaces hire and subscription` (+ `7fb3dce4`, the plan read waits for a Skip still in flight). - [x] **Phase 3** `61708f3c feat(frontend/onboarding): hire the plan's team after the trial is confirmed`. - [x] **Phase 4** `130b92ad feat(frontend/home): suggested tasks ranked and owned by experts, list restored, remove and collapse` (+ `3782dfc4`, a started task names the expert its chat is with). - [x] **Phase 5** `e075809d` tracking audit fixes and the tracking plan, `177f4fad` the Playwright helper, `044c4fdb` edge-case coverage (a Jev outage logs one warning, an unset key warns once at startup, an empty team hires nobody, the walkthrough lands on the pill's row), `6eda537b` focus after Remove never drops to the page (found by the final review), `8badd23e` one ref write out of render (a lint warning this PR added), `d113cf2c` the plan's hire checks the paywall and the plan read is listed open (CI's `paywall_routes_test` requires one or the other for every route). - [x] **The plan step's wait** (found testing the preview) `9532c2b7` the step stops waiting on work the plan does not use and a plan it gives up on is the plan, `2abf8821` the settle route listed open while paywalled, `418ae59c` review fixes. See "The plan step's wait" below. - [x] **Merged with dev again** `8d81826a`, for dev's expert-roster test update (#16443). Checked on the merge: the exported API schema matches `openapi.json`; frontend types and lint pass, and onboarding, Home's task list and analytics run 1,053 tests, all passing; backend `experts` + `onboarding_dump` + `paywall_routes_test` has 1,170 passed, and the one failure needs ClamAV, which this environment does not run. - [x] **Kept current with dev** `075f17a9` (checked: schema matches `openapi.json`, frontend types and lint pass, 506 test files / 6,510 tests pass in onboarding, copilot, services and hooks, backend `onboarding_dump` + `paywall_routes_test` 637 passed), `538606ae`, `28e82949`, `6a8e6052`, `21ec4514`, `89750735`, `28a7f663`, `9a7fa159`, and `c365e277` (a port of #16489, dev's fix for the MCP tests it had broken). `9a7fa159` took dev's colour-role refactor (#16511): the hire-step Playwright fixture it recoloured stays deleted, and its `ds-colour-exempt.json` entry for `OttoCover.tsx`, a file this branch deletes, is removed. Test-only ports for CI flakes outside this PR: `52a5f637` (#16449, the PostHog consent test's capture interval) and `36527ca2` (#16566, the shared auth mock's session exports; #16565, the external API concurrency test's Redis timeout). They no-op once dev carries those PRs. The conflicts: dev's Langfuse prompt client in `intro.py` (#16026); a storage key both sides added (#16527); dev's Text, Icon and Button props on the Suggested Tasks components this branch rewrote (#16513: kept this branch's versions, moved two colour classes to `tone`, regenerated the design-system baseline); dev's `ENABLE_PLATFORM_PAYMENT` setting (#16459); and dev's removal of unused exports (#16163), which dropped `SELF_HOST_STEPS` from the onboarding store while this branch had changed it (`075f17a9`: kept this branch's plan step in `NO_PAYWALL_STEPS` and left `SELF_HOST_STEPS` deleted, since nothing imports it). Dev's palette removal (#16612) also added a lint-rule test case that relies on the exemption for `OttoCover.tsx`, which this branch deletes; `000a3f88` points that case at `CodeRenderer.tsx`'s allow-list instead. The paywall test's PostHog mock now returns the event, as dev's own tests do since #16518. - Since #16459, the no-paywall version of the plan step is what an install gets while `ENABLE_PLATFORM_PAYMENT` is off, which is the default. The plan step is step 5 either way; only the paywall beside it changes. - Checked on the last merge: the exported API schema matches `openapi.json`; frontend types and lint pass, and onboarding, copilot, services, hooks and the paywall gate run 485 test files (6,442 tests), all passing; backend `onboarding_dump`, `experts`, `paywall_routes_test` and dev's payment-setting and trial tests: 1,326 passed, and the one failure needs ClamAV. - [x] **The greeting has a fast backup, so the brain dump shapes the plan** (found testing the preview) `2c416169`, with review fixes in `a50305f3`. After a real brain dump, the plan step kept showing the role's starter tasks. The greeting, which writes the plan's tasks, either failed (the generic greeting) or was still running when the step stopped waiting at 30 s. Sonnet writes long answers for a long take, and this PR turned its thinking off, which is proven on Haiku 5.5 but not on Sonnet 5.5. `greeting_race.py` runs Sonnet first, and Haiku 5.5, which the team job already calls the same way, joins it. Haiku starts at once when Sonnet fails or gives an unusable answer, or alongside Sonnet once it has run 12 s. The first full answer wins and the other call is cancelled. Each attempt logs its model and why it failed, and the winner logs its model and time. Sonnet is now asked for by OpenRouter's own id, `anthropic/claude-sonnet-5.5`. A two-lens adversarial review confirmed three defects, now fixed in `a50305f3`: a malformed reply could escape the race and fail the dump, a test passed for the wrong reason, and the env example was stale. Checked: `onboarding_dump` + `paywall_routes_test`, 654 passed. - [x] **Every plan starts with a team of three** (asked for after testing the preview) `d6ea5524`. Without a brain dump, the team came from the wizard role alone: two experts for Sales/BD, Operations and HR/People, none for Other. Each role now names three skills that cover its starter tasks. Sales/BD adds Quinn for the account research; Operations and HR/People add Kai, for the self-chasing tracker and the interview scheduling. A brain-dump team with fewer than three is topped up with the wizard's picks after the model's own. Checked: `onboarding_dump`, `experts` and `paywall_routes_test`, 1,255 passed. - [x] **Tasks go to the experts whose roles fit them** (found testing the preview) `29476fbb`, with review fixes in `676a6b34`. A product manager's plan named Alex, Quinn and Nadia as the team and every task as Otto's. The owner match compared exact role strings ("research", "bookkeeping") with the catalog's roles, which now read "Research, Data & KPI Analysis", "Market & Competitor Intelligence" and "Product", so nobody matched. Now a role's skills come from the words it contains (`role_skills.py`), and the role sets, the title and icon hints and the fallback team all name skills. Against the live roster, every role's starter plan is its team's work: Product gets Alex, Alex, Nadia, Quinn, Alex. The one exception is Engineering's customer-questions task, which no engineering expert covers. Among teammates with a skill, the most focused one takes the task, so Maya (Marketing) writes the newsletter, not Clip (Motion Graphics). Title hints read the task's subject, not a word in passing: a "feature" in a marketing title or a sales "pitch" no longer decides. New hints cover CRM, contracts, security, bookkeeping and fundraising work. A task whose best match is cut from the three-person team goes to the next match who stays, not to Otto. An empty seat goes to a later task's owner before someone with no task. A four-lens adversarial review checked this against the live roster, with each finding verified. The greeting's failure log now names the exception type, because a timeout's message is empty. - Checked: the onboarding suite (613 tests) passes; experts + `paywall_routes_test` pass (638 passed, 30 skipped); pyright is clean on the changed package. The new `role_skills_test.py` pins owners and teams against the catalog's 33 roles. See "The greeting fell back on the preview" below. - [x] **Review round (kcze)** `4015926a`, then merged with dev `6c365f60`. 18 of the 19 findings fixed, each with a test that failed first; `d286bd1f` fixes the 19th, with `e438b230` and `4ea3cc78` correcting that commit: - Settling keeps the tasks the greeting already wrote instead of swapping in the role plan, and the role plan's template read is cached for 60 s. - `plan_experts_hired` moved to the backend and counts only experts that joined in that call. - Preparing retries a hire that got no answer inside its 15 s window, toasts who couldn't join, refreshes Home's task owners, and waits (bounded) for a ready plan when none was shown. - The plan step bounds and retries its settle, keeps polling behind a fallback the server never stored, ignores stale answers, and remembers only stored plans. - Checkout is back to "within a trial usage allowance". - Home holds owned tasks while the roster read fails, doesn't carry a new-chat mode over to another recipient, and only restores a recipient it changed. - `d286bd1f`, `e438b230` and `4ea3cc78` then cover the last finding and two gaps. `hire_plan` refuses a plan that `settle_plan` could not settle (409, nobody hired, no event) and the client retries it inside its window. This replaces the earlier choice to hire anyway: a user with nobody is now the cost only if every retry loses too, and then the toast says so. A refresh mid-hire re-asks once the first call's time is up, with its own retry window: the answer does the hire if the first call was lost, says who did not join, and refreshes Home. If no answer comes, it shows no toast, because the first call may have worked. Home's owners refresh whatever the answer. A hire nobody could join is not tried again, because the answer can't tell a blip from the expert limit. `plan_experts_hired` counts a failure once, through a Redis claim that gives up after 1 s and reports anyway. - Left as is: when no answer ever comes on the first hire, the toast names the whole plan even though a lost answer may have hired them (rare, and Home's roster still refreshes). A refresh whose first call was lost and whose re-ask never gets an answer leaves nobody hired and shows no toast; only Sentry sees it (rarer still). Failed whole calls (a 409 or 5xx) send no `plan_experts_hired`, as the tracking plan says. - Checked on the merge: the exported API schema matches `openapi.json`; frontend types and lint pass; backend `onboarding_dump`, experts routes and db, and `posthog_events` tests pass (1,025 passed, 19 skipped); frontend Vitest runs 1,220 files (13,685 passed, 4 expected failures). - [x] **Review round (Capy, CodeRabbit)** `32bac59b`, after merging dev in `0d7379cf`. Each of the 31 findings was checked against the code: 8 are fixed, and the other 23 are answered on the PR with the reason each stays as is. - Home's suggested-task reads fail when the roster can't be read and a task names its owner, instead of handing every owned task to Otto. The plan read and the plan hire keep the lenient read. - Each plan-hire try gets a 7 s deadline while another try still fits in the 15 s window, so a stalled request is sent again. The last try waits for its answer; the proxy's 30 s timeout ends it with the failure toast. - The plan step's settle retries stop once the step is left, so they can't land on a newer take. Paid checkout stays locked while the trial is starting. - Removing the last task on a touch screen moves focus to a composer button, not the text box that opens the keyboard. The folded list is inert while it closes. A roster read still running doesn't count against the task-switch give-up. - Checked: CI green on `32bac59b` (project coverage 90.61%, +0.04%). - [x] **Merged with dev** `88fefd94` (the conflicts were dev's design-token and Tailwind 4 refactors on files this branch deletes or rewrote) and `ef8ccdb6` (this branch's components on dev's design-system lint; see "Where this differs"). Final counts: | | Before (dev, Phase 0) | After (`6eda537b`) | After the dev merge (`ef8ccdb6`) | | --- | --- | --- | --- | | Frontend `pnpm test:unit` | 12,282 passed + 4 expected failures, 1,127 files | 12,471 passed + 4 expected failures, 1,135 files | 12,834 passed + 4 expected failures, 1,164 files | | Backend `onboarding_dump` + `experts` | 904 passed, 29 skipped | 1,073 passed, 29 skipped | 1,172 passed, 29 skipped (with `paywall_routes_test` and dev's `onboarding_role` and `onboarding_wizard` tests) | The deleted hire-step, card and trial-welcome tests are inside those numbers; the count went up by 189 regardless. `8badd23e` (after the full run) moves one ref write into an effect; the 622 onboarding tests pass on it. `d113cf2c` adds one backend test; `onboarding_dump` + `paywall_routes_test`: 526 passed. ### Before and after All screenshots, side by side, desktop (1440 × 900) and phone (390 × 844): **[Plan-first trial: before and after](https://claude.ai/artifact/N4aZGHKWh3Ug3upSVKNA83)** (a private Claude page: it opens for people it has been shared with). It covers: | Screen | Before (dev) | After (this PR) | | --- | --- | --- | | The step after the brain dump | Hire step, then the trial welcome | One plan + trial step (brain dump and skipped; paid and no-paywall variants; pending skeleton; 1280 × 720 and 1366 × 768 without scrolling; 320 × 640 with the sticky trial button) | | Preparing | Checklist | Checklist with "Hiring Maria, Max and Mina" | | Home under the greeting | Two-column cards | The ranked, owned list with the Your team row | | Home after the greeting | Hide all | Hide / Show, "{n} more tasks", a started task, the "Task removed" toast | | The walkthrough's last step | Lands on the first card | Lands on the row with the Start here pill | The screenshots come from `next dev` with every API call mocked (no backend, no Stripe here). The app renders light only (`forcedTheme`), so there are no dark captures. ### Changes 🏗️ **Backend (`onboarding_dump`)** - `plan.py`: one Jev call ranks the greeting's tasks and picks owners; every failure (no key, timeout, raise, cut state, malformed answer) returns None and the plan falls back. `plan_owners.py`: the deterministic fallback, the team cap of three, and owners matched by skill among the three the plan keeps. `role_skills.py`: a template's skills, read from the words of its role, so a renamed role still matches. `plan_job.py`: the last step of the background pipeline, a conditional write on `updatedAt`, retried, Jev asked once. `plan_service.py`, `plan_models.py`, routes: `GET /plan` (open while paywalled: it is shown on the paywall step), `POST /plan/hire` (checks the paywall, so a user who left Stripe without paying hires nobody, whatever the client sends). - `role_tasks.py` / `role_task_sets.py`: five starter tasks per wizard role, each naming the skills that take it, owned from the role's fallback team. `recommend_experts.py`: the fallback team is the roster's most focused template for each skill, starting with the role's first skill, then the user's pain points, then the role's other skills. Each role's skills cover its starter tasks. - `SuggestedPrompt` gains `expert_template_id`, `why`, `expert_name`; `SuggestedTask` gains read-time `expert_id`. `suggested_tasks.with_expert_ids` reads a started task's expert from its chat (`get_chat_session_expert_ids`), so firing that expert, or hiring the owner after Otto took the task, does not relabel the chat. `expert_hired` gains `via: plan`. **Onboarding (frontend)** - `SubscriptionStep/PlanTrialStep.tsx` with `PlanPanel` (polls every second and shows the server's progress as a checklist, settles the plan at 30 s, never swaps a shown plan, restores it on a Stripe cancel return), `TrialPanel`, `PaidPanel`. The trial date math moved to `TrialPanel/helpers.ts` with its tests. Deleted: `HireStep/`, `SubscriptionStep.tsx` and the trial welcome components (#16036) the new step does not use. - `store.ts` drops the hire step; saved progress that names it resumes on the plan step. - `usePlanHire.ts` + `usePreparingStep.ts`: one hire after confirmation, retried inside its window (sessionStorage guard against a remount), "Hiring {names}" or "Setting up your team" in the checklist, the bar holds up to 15 s and then goes on, roster queries refreshed. **Home (frontend)** - `SuggestedTaskList` + `components/TaskRow`, `TaskOwner`, `YourTeamRow`; `SuggestedTasksBlock` with the Hide / Show toggle (`useCollapsedSuggestions`, per user in `localStorage` under `home.suggestedTasks.collapsed`); `useSuggestedTasks` split into `useSuggestedTaskList` and `useTaskRecipient`. Deleted: `SuggestedTaskCard.tsx`, `SuggestedTaskCards.tsx`. - Sending: the composer's recipient becomes the owner (`?expertId=`) and the task goes out once the page can send; a switch that does not land in 3 s gives up with a failure toast and puts the recipient back. An owner who is not on the team: Otto takes it, with "{Name} isn't on your team, so Otto has this one." only when the roster actually loaded. - Accessibility: rows are named "Start with {Name}: {title}" / "Open the chat with {Name}: {title}" and described by their pill and why; a starting task is announced; focus moves to the neighbouring row on remove and back to the row when a start fails; inset focus rings; text and icons at AA contrast. **Tracking, docs, tests** - `posthog-events.ts`, `plan-analytics.ts`, `experts-analytics.ts`, `expert-thread-source.ts`; `docs/engineering/tracking-plan.md` updated (below). - `src/playwright/utils/onboarding.ts`: `completeOnboardingWizard` waits for `plan-panel` to lose `plan-panel-pending`, presses Continue, then connect or preparing. - Two new optional settings, both commented out in `.env.default`: `BRAIN_DUMP_GREETING_BACKUP_MODEL` (the greeting's fast backup) and `BRAIN_DUMP_PLAN_JEV_MODEL` (the Jev model; unset means the SDK default). No new feature flag, no data migration. `openapi.json` is regenerated (the generated client is gitignored). ### Tracking Both walks ran in the mocked app with every `posthog.capture()` call recorded (twice each, identical). App events only; `$autocapture` and `$pageleave` are left out. **The walk ran before `e075809d`**, which renamed two properties after the audit (see "Where this differs"); the lists show the names the code sends now, and the unit tests pin them. <details><summary>Path A: brain dump typed (20 app events)</summary> | # | Event | Properties | When | | --- | --- | --- | --- | | 1 | `onboarding_step_viewed` | `step: team` | /onboarding | | 2 | `onboarding_step_viewed` | `step: autopilot` | Next | | 3 | `onboarding_step_viewed` | `step: role` | Next | | 4 | `onboarding_step_viewed` | `step: pain_points` | role Next | | 5 | `brain_dump_typed_fallback` | `reason: chose_to_type, prompt_set: Founder/CEO` | Write | | 6 | `brain_dump_completed` | `input_mode: typed, chars: 283, prompt_set: Founder/CEO, prompts_seen: 1` | I'm done | | 7 | `paywall_viewed` | `surface: onboarding` | plan step opens | | 8 | `onboarding_step_viewed` | `step: subscription` | plan step opens | | 9 | `plan_viewed` | `plan_source: llm, skipped_dump: false, tasks: 3, experts: 3, wait_ms: 84, gave_up: false` | plan step opens | | 10 | `trial_offer_viewed` | `trial_offer_version: trial-v1, subscription_tier: PRO, trial_duration_days: 7, surface: onboarding` | plan step opens | | 11 | `onboarding_step_viewed` | `step: preparing` | Start my free trial → Stripe → back | | 12 | `plan_experts_hired` (backend) | `hired: 3, already_on_team: 0, failed: 0` | preparing | | 13 | `intro_path` | `path: A` | Home | | 14 | `home_suggestion_clicked` | `position: 0, surface: intro, remaining: 5, owner: expert, expert_template_id: tpl-maria, rank: 0` | task 1 | | 15 | `intro_followup_sent` | `chars: 115` | task 1 | | 16 | `expert_thread_created` | `expert_id: exp-maria, thread_source: suggested_task` | task 1 | | 17 | `home_viewed` | — | back to Home | | 18 | `home_suggestion_dismissed` | `position: 1, surface: home, remaining: 4, scope: one` | x on task 2 (Undo sends nothing) | | 19 | `home_suggestions_collapsed` | `open: 5` | Hide | | 20 | `home_suggestions_expanded` | `open: 5` | Show | </details> <details><summary>Path B: brain dump skipped (19 app events)</summary> | # | Event | Properties | When | | --- | --- | --- | --- | | 1–4 | `onboarding_step_viewed` | `step: team`, `autopilot`, `role`, `pain_points` | as in A | | 5 | `brain_dump_skipped` | `prompt_set: Founder/CEO, prompts_seen: 0` | Skip for now | | 6 | `paywall_viewed` | `surface: onboarding` | plan step opens | | 7 | `onboarding_step_viewed` | `step: subscription` | plan step opens | | 8 | `trial_offer_viewed` | `trial_offer_version: trial-v1, subscription_tier: PRO, trial_duration_days: 7, surface: onboarding` | plan step opens | | 9 | `plan_viewed` | `plan_source: fallback, skipped_dump: true, tasks: 3, experts: 2, wait_ms: 103, gave_up: false` | plan step opens | | 10 | `onboarding_step_viewed` | `step: preparing` | Start my free trial → Stripe → back | | 11 | `plan_experts_hired` (backend) | `hired: 2, already_on_team: 0, failed: 0` | preparing | | 12 | `intro_path` | `path: B` | Home | | 13 | `home_suggestion_clicked` | `position: 0, surface: intro, remaining: 4, owner: expert, expert_template_id: tpl-max, rank: 0` | task 1 | | 14 | `intro_followup_sent` | `chars: 63` | task 1 | | 15 | `expert_thread_created` | `expert_id: exp-max, thread_source: suggested_task` | task 1 | | 16 | `home_viewed` | — | back to Home | | 17 | `home_suggestion_dismissed` | `position: 1, surface: home, remaining: 3, scope: one` | x on task 2 (Undo sends nothing) | | 18 | `home_suggestions_collapsed` | `open: 4` | Hide | | 19 | `home_suggestions_expanded` | `open: 4` | Show | </details> None of the removed events appeared. Sent by the backend, so not in a browser walk here: `signup_completed`, `checkout_started` (`checkout_kind: trial, surface: onboarding`), `trial_started`, `trial_ending`, `trial_canceled`, `trial_converted`, `payment_succeeded`, `expert_hired` (3 in A, 2 in B, `surface: onboarding, via: plan`). Not reached by the walk's actions, covered by unit tests: `plan_selected` (paid path, `PlanTrialStep.paywall.test.tsx`), `checkout_abandoned` (cancel return), `welcome_dialog_closed` (the walkthrough was marked seen). `capability_card_*` were retired before this branch (SECRT-3149) and stay reserved. **Removed with the hire step:** `expert_recommended`, `expert_recommendation_clicked`, `hire_all_clicked`, `hire_step_continued` (in `RETIRED_EVENT_NAMES`, rows in the tracking plan's Removed table). `hire_started` loses its `onboarding` surface (the plan's hires have no click); it stays for `expert_page`. **After deploy:** four saved PostHog insights read the removed events (u2ZK2Isk, QfcpXRmM, 2OSZpNcv and ugcxrNQU). SECRT-3257 has the steps to freeze each one at the release and the tested queries for the plan-first versions, to do once this is live on prod. ### Langfuse Tracked in SECRT-3225, to do before this reaches prod. The greeting's instructions come from the Langfuse prompt **Brain Dump Greeting** (`BRAIN_DUMP_GREETING_PROMPT_NAME`) whenever it is reachable; the local copy in `intro.py` is only the fallback. The local copy now asks for one optional key per suggested task: > `"why"` (optional): one short clause, at most 60 characters, second person, naming the thing they said this task fixes ("Because listings eat your evenings."). Leave the key out when nothing they said ties to the task. The same line needs adding to the Langfuse copy, or every task in prod arrives without a why line (nothing breaks; the line is just absent). The parser keeps a `why` of 1 to 60 characters and drops a longer one rather than cutting it. Nothing else in the greeting prompt changes. ### Settled decisions - The paywall is replaced entirely by the plan + trial step; `useSubscriptionStep`, `useTrialCard`, `tracking.ts`, the trial date math (moved, tests kept) and the Stripe return handling stay. - Tasks are ranked and assigned by TypeSafe Jev with a deterministic fallback. Jev is a hosted model call: the request carries the role, pain points, tasks, team and up to 6,000 characters of the transcript, with a 5.5 s budget. - Experts are hired on the preparing screen after the trial is confirmed; a user who abandons Stripe hires nobody (with no paywall, when the user continues past the plan). - The list is the design, the #16236 cards are removed; x removes a task, Hide all became a collapse. ### Where this differs from the handoff, and why - **`plan_source` and `thread_source`, not `source`.** `docs/engineering/tracking-plan.md` reserves `source` for the emitter: every browser event carries `source: web` as a super property, and an event property of the same name replaces it. The repo settled this clash before with `team_source` and `workflow_source`, so `plan_viewed` sends `plan_source` and `expert_thread_created` sends `thread_source`. - **A started task keeps naming who has it.** Edge case 11 (a fired expert's tasks go to Otto) holds for open tasks. A task already started links to a chat with that expert, so its row keeps their name; the server reads it from the chat. Without this, firing Maria relabelled her chat as Otto's, and hiring an owner after Otto took a task relabelled Otto's chat. - **Contrast.** The spec's "muted" grey (`#83838C`) is under 4.5:1 for small text, so the why line, the role, the sent rows' title and the toggle use `foreground-secondary`, and "In progress with {Name}" uses `success-foreground` rather than `success`. - **Undo focus.** Focus moves to the neighbouring row on remove. After Undo, the toast (sonner) returns focus to where it was before the toast, which is that neighbour, rather than to the restored row; fighting the toast's own focus return would make focus jump twice. - **The brain dump step** gained one read-only accessor (`whenSkipSettled`), so the plan read waits for a Skip still in flight (`7fb3dce4`). Its behaviour is unchanged. - **Module split.** The backend plan lives in seven small modules instead of one `plan.py`, because backend AGENTS.md caps files at about 300 lines. - **Jev request budget.** Owner candidates are the recommendation's first 6, each task's prompt is cut to 600 characters / 1,200 bytes, and the transcript to 6,000 characters / 12,000 bytes, then 6,000 and 3,000 bytes if the state still does not fit. A character-only cut sent every long Japanese or Chinese dump to the fallback. - **The plan API's `source` is `llm`** only when the greeting wrote the stored tasks. A completed dump whose greeting fell back to the generic starters gets the role set (owned from the dump's team) and reads as `fallback` with no "heard" line. - **Pending plan.** While the pipeline runs, `GET /plan` answers `ready: false` with the role plan, unstored, and `progress` (tasks written, team chosen). A voice take still transcribing counts as pending. At 30 s the step calls `POST /plan/settle`, which stores that role plan as the take's tasks and team unless a plan is already stored; see "The plan step's wait". - **Reads that store** are built from reads that raise, so a failed template or wizard read fails the request (the step keeps polling) instead of storing a degraded list for good. - **Ids.** Ranked rows keep their original position as `id`, so a task keeps the id Home may already have handed out. Each owned task stores `expert_name`, so the Otto toast can name an expert who has left. - **Hire while pending (no paywall only).** A hire that arrives before the pipeline finishes settles the plan first, so the team hired is the role team whose tasks Home then lists; with the paywall the Stripe round trip makes this window practically zero. - **Archived experts.** A repeat plan hire can bring back an expert the user archived, but only on an onboarding retake whose new plan names them again. - **Merged with dev's design-system lint (#16402, #16429).** The new rules fail any violation in a file the baseline doesn't list, so this branch's new components now use the primitives' own looks (`ef8ccdb6`). Visible differences from the screenshots: avatars keep the design system's own shape (rounded-xl) instead of a full circle, as dev's onboarding does; the row's remove button is the Button's `toggle` variant, quiet grey with no hover fill; the sending spinner is `LoadingSpinner` (16px); the two 48px CTAs set their label at 15px (`textSize="body-md"`) instead of 16px; the step card's corner is 24px instead of 28px; the Start here pill is 11px with `tracking-eyebrow`. The baseline only goes down (the task list 13 → 1, the block 9 → 3, the deleted card's entry gone). Dev's role-draft test (#16398) now steps from the brain dump straight to the plan step. - **Commit trailer.** AGENTS.md asks for a `Co-authored-by` trailer naming the model and the platform; the commits use the session's trailer with the platform added. ### The plan step's wait Found testing the preview: the step waited for the whole brain-dump pipeline. That was the Sonnet greeting with adaptive thinking on, the team and integration picks, the memory extraction, the Jev ranking, then the dump's `completed` status. Past the old 20 s give-up, the step showed the role plan while the hire and Home used the pipeline's, so the team named on the step could differ from the one hired. Fixed in this PR: - **Less to wait for.** The greeting call answers without thinking first (`intro.py`, reasoning off like the pipeline's other calls, `max_tokens` 4,000). The team job asks for the six experts the plan reads (`plan.MAX_OWNER_CANDIDATES`), not sixteen. Only the greeting, team and plan jobs end the step's wait. The provider picks and the memory extraction run beside them and write their own columns; a failed understanding write now costs the context, not the take. - **Progress, not skeletons.** The pending answer carries `progress`. The step shows it as a checklist where the heard card will be: "Turning your brain dump into tasks", "Finding experts who fit your work", "Choosing who takes each task". It polls every second (was 2.5 s) and announces "Your plan is ready" from a live region outside the busy skeleton. - **A plan the step gives up on is the plan.** The step gives up at 30 s (was 20 s). It then calls `POST /plan/settle`, which stores the role plan it showed as the take's tasks (rows with ids and states) and team. The greeting still writes its text, but leaves tasks already stored alone (`db.store_greeting`, conditional on an empty list). The team job leaves a stored team alone (`db.replace_team`, a conditional write on `updatedAt`, because a reset take holds a JSON null and an older row a SQL null). The plan job finds nothing to rank. So the trial hires, and Home lists, what the step showed. The cut-off moved to 30 s because giving up now costs the plan the brain dump would have written. - **Back and re-record.** The remembered plan (for a Stripe cancel return and the preparing screen's label) is forgotten whenever a new take or skip is sent. - **Measuring it.** The preview sends nothing to PostHog, so the new wait isn't measured yet. The backend now logs each leg ("Brain dump greeting for user … took Ns", "Brain dump team for user … took Ns", "Brain dump plan ready for user … Ns after the pipeline started"). `plan_viewed`'s `wait_ms` and `gave_up` give the same in PostHog after release. `gave_up` now means the 30 s wait ran out, and `plan_source` says whose plan was shown. - **Tests.** BE: `service_test.py` (the plan is ready before the provider picks and before the understanding is stored, a failed understanding write costs the context, tasks and team stored while the jobs ran stand, a lost team write is retried), `routes_test.py` (pending progress, settling mid-pipeline, settling a ready plan changes nothing, settling a take still being transcribed reads as recorded, hiring while the jobs run settles first, and the end-to-end "a plan the step gave up on is what the trial hires and Home lists"), `suggested_tasks_db_test.py` (the conditional writes against Postgres), `intro_test.py` (reasoning off). FE: `usePlan.test.tsx` (1 s polls, settles at 30 s, falls back when the settle fails, waits for a skip in flight, keeps a plan that lands during the settle), `PlanPanel.test.tsx` (the checklist and the announcement). ### Not run here, and notes - **Stripe test mode and a real `posthog.debug()` walk.** This environment has no backend stack or Stripe account. The flow was walked in `next dev` with every call mocked (Stripe Checkout intercepted to the success return), with PostHog captured in the page. The Playwright smoke and journeys suites ran in CI and passed with the updated helper. - **CI: two `experts_db_test` failures from skills-catalog `main`.** Catalog PR #24 (30 Oct 15:50 UTC) changed day-one rows and roles, which broke `test_wave_three_experts_are_skills_only_with_ordered_packs` and `test_roster_day_one_promises_match_work_the_expert_can_do_on_request` on every branch ([details](https://github.com/Significant-Gravitas/AutoGPT/pull/16308#issuecomment-6527907044)). Dev fixed them in #16443, merged here in `8d81826a`. - **Out of scope, seen in passing.** `home_viewed` does not fire on the first Home visit, because `HomeRecap` mounts only once the greeting has retired; the same on dev. The app sidebar logs a hydration mismatch on some loads (its mobile/desktop branch differs between server and client); the dev captures show it too. - **`POST /api/experts` stays open while paywalled.** Its entry in `paywall_routes_test.py` dates from when onboarding hired the team before the plan picker. Onboarding no longer calls it; whether it should now check the paywall is a separate decision, so I only updated the comment. - **The greeting fell back on the preview.** In the product manager's test, Home showed the generic greeting ("Thanks for talking me through your work — I've got it…"). That means the greeting call failed twice, and the plan then took the Product/PM starter set instead of tasks written from the brain dump. The owner fix above makes that set the team's work, but the tasks still come from the role, not from what the user said. The cause is not visible from here: no model keys in this environment. The preview backend logs it as `Brain dump greeting generation failed (attempt N): <ErrorType>: <message>`, now with the type. - **`Other` and the team of three.** Every plan now starts with three experts (`d6ea5524`). For Other, or no role, the user's pain points lead, then the experts the Other starter set needs (Quinn, Maya, Nadia), so its research, post, summary and watch tasks have owners; its first task, finding the work to hand off, stays Otto's. On a thin roster, any seat still empty is filled from general skills and never with a role that names no skill, so a filler may own no task. ### Edge cases Each of the handoff's 16 cases, with the tests that cover it. Paths: **BE** `backend/api/features/onboarding_dump/`, **OB** `frontend/src/app/(no-navbar)/onboarding/`, **PP** `OB/steps/SubscriptionStep/components/PlanPanel/__tests__/`, **ST** `frontend/src/app/(platform)/copilot/components/SuggestedTasks/__tests__/`. | # | Case | Covered by | | --- | --- | --- | | 1 | Skipped brain dump | BE `routes_test.py::test_a_skipped_dump_gets_the_role_plan_stored_on_first_read` (fallback, `skipped_dump`, Founder team, Home gets the same titles and ids); PP `PlanPanel.test.tsx` "speaks to the role for a skipped dump" | | 2 | Dump still generating, 30 s give-up settles the plan, no swap | PP `usePlan.test.tsx` "settles the plan at 30 s and shows the plan the server settled on", "keeps the settled plan when a poll in flight at 30 s lands later", "keeps a plan that lands while the settle is on its way"; BE `test_the_plan_is_pending_while_the_pipeline_runs` (a pending read stores nothing), `test_a_plan_the_step_gave_up_on_is_what_the_trial_hires_and_home_lists`. A *failed* dump answers at once with the role plan (`test_a_failed_take_plans_and_hires_from_the_role_not_the_team_it_stored`). | | 3 | Jev unset, timeout, error, cut state | BE `plan_test.py` (no key, timeout or raise, slow call, failed/empty/cut call, oversized state all return None; a timeout or raise logs one warning), `plan_job_test.py::test_without_jev_the_greeting_order_is_kept_with_hinted_owners`. An unset key warns once at import, like the copilot gate. | | 4 | Zero experts | BE `plan_owners_test.py::test_no_recommended_experts_leaves_every_task_with_otto`, `routes_test.py::test_a_plan_with_nobody_on_its_team_hires_nobody`; PP "leaves the tasks with Otto when nobody joins"; OB `usePreparingStep.test.ts` "says it is setting up the team when the plan has nobody to hire" | | 5 | Fewer / more than three | PP "shows fewer cards when the plan has fewer tasks"; ST `suggested-tasks.test.tsx` "keeps the rows after the first three under a closed '2 more tasks' row that opens on click" | | 6 | Owner outside the capped team | BE `plan_owners_test.py::test_a_team_of_four_owners_keeps_three_and_nulls_the_fourth`, `plan_job_test.py::test_a_jev_owner_left_off_the_capped_team_goes_to_otto` | | 7 | Back and re-record | PP `usePlan.test.tsx` "does not show a plan cached before the mount while the refetch is pending", "reads the plan only once a skip still on its way has landed"; BE `service_test.py::test_a_new_take_never_serves_the_previous_takes_answers` | | 8 | Stripe cancel return | PP "shows the remembered plan at once on a checkout return", "does not report a restored plan as a second view"; OB `PlanTrialStep.paywall.test.tsx` "reports checkout_abandoned when Stripe sends the user back without paying"; `usePreparingStep.test.ts` "never hires before the trial is confirmed"; BE `routes_test.py::test_a_user_still_behind_the_paywall_hires_nobody` | | 9 | No trial offer | OB `PlanTrialStep.paywall.test.tsx` "offers one Pro checkout beside the plan…"; PP "names the moment the team joins" ("joins your team when you subscribe") | | 10 | Hire fails for one or all | BE `test_a_failed_hire_is_reported_and_the_rest_still_join`; OB "never blocks Home on a failed hire, and reports it", "moves on after the ceiling when the hire never answers"; ST "shows each row's owner… and Otto… for an expert not on the team" | | 11 | Expert fired between visits | BE `suggested_tasks_test.py::test_each_owned_task_names_its_hired_expert`, `test_a_started_task_stays_with_the_expert_its_chat_is_with_once_fired`; ST "gives Otto a task whose expert has left the team, says so, and shows it in progress with Otto", "keeps naming the expert a started task went to after that expert is fired". The toast fires when such a task is started, once (the row then becomes a link). | | 12 | Old progress with `hire` | OB `progress.test.ts` / `page.test.tsx` "resumes a draft saved on the retired hire step at the plan step"; `store.test.ts` "never lays out the retired hire step" | | 13 | Old rows without owner or why | BE `suggested_tasks_db_test.py::test_prompts_stored_before_owners_existed_still_rehydrate`, `intro_test.py::test_a_prompt_keeps_its_why_and_one_without_still_parses`; ST "shows a task's why under its title, and nothing there when it has none" | | 14 | Role `Other` | BE `plan_owners_test.py::test_other_and_unknown_roles_take_the_other_set`; PP "keeps the line role-free for Other". See the note on `Other` above for the team. | | 15 | Motion, keyboard, viewports, phones, collapsed list | PP "offers nothing to click or focus"; ST `SuggestedTasksBlock.test.tsx` "still hides and shows the list when the user prefers less motion", "keeps the list folded for the same user after a reload…", `helpers.test.ts` "never collapses the list for a signed-out visitor…". By reading the code and in the screenshots: tab order on the step is Back, Start my free trial, Log out (DOM order; the progress bar has nothing focusable); 1280 × 720 and 1366 × 768 fit without scrolling and phones keep the sticky trial button (screenshots); rows stack under `sm` with the x always visible on touch; the collapse is a per-user key that sign-out does not clear. | | 16 | Expert limit mid-hire | BE `test_the_expert_limit_stops_the_hire_and_reports_the_rest`, `test_the_expert_limit_on_the_second_hire_keeps_the_first_and_reports_the_rest`; Home's Otto fallback is case 10's path | Screen reader and keyboard: rows announce "Start with Maria: {title}" and, once sent, "Open the chat with Maria: {title}" (ST); the toggle reports expanded and collapsed (ST); the walkthrough's Start here step lands on the row with the pill (`first-run-tour.test.tsx` "starts on a task's own button, never its Remove button"); focus after Remove goes to the next row, its Remove while locked, or the composer when the list goes (ST "keyboard and screen readers", `SuggestedTasksBlock.test.tsx` "focus after Remove"). ### Ticket lines Resolves SECRT-3201 Resolves SECRT-3132 Resolves SECRT-3131 Progresses SECRT-3134 (Start here and ranking shipped; account requirements per task still open) Progresses SECRT-3142 (owner per task shipped; Otto proposing delegation in chat still open) Related SECRT-3211 (what happens to a task after its chat finishes), out of scope Related SECRT-3212 (integrations on suggested tasks), out of scope Related SECRT-3225 (the Langfuse why line, before release) and SECRT-3257 (the PostHog insights, after release) ### Agents and large language models used - Claude Code (model reported in the commit trailers) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan (all but a Stripe test-mode run, below): - [x] Backend: `poetry run pytest backend/api/features/onboarding_dump backend/api/features/experts backend/api/paywall_routes_test.py` plus dev's `onboarding_role` and `onboarding_wizard` tests: 1,172 passed, 29 skipped after the dev merge. `export-api-schema` matches the committed `openapi.json`. isort, black and pyright clean on every changed file. - [x] Frontend: `pnpm lint` and `pnpm types` exit 0 on the final head (the only warnings left in files this PR touches predate it); `pnpm test:unit`: 12,834 passed + 4 expected failures after the dev merge, including the design-system baseline tests. React Compiler coverage and bailout tests pass with no new bailouts. - [x] Each commit checked on its own in a clean worktree (types and the suites it touches). - [x] Screenshots of every changed screen, desktop and phone, plus 1280 × 720, 1366 × 768 and 320 × 640 for the plan step (linked above). Mocked `next dev`; light theme only. - [x] Both onboarding paths walked to Home with every PostHog `capture()` recorded, twice each (lists above). - [x] Two review passes with independent verification of each finding; the confirmed ones are fixed in `3782dfc4` (a started task's owner), the give-up fix in `130b92ad`, and `6eda537b` (focus after Remove). - [x] Playwright smoke and journeys suites: passed in CI with the updated `completeOnboardingWizard`. - [ ] Stripe test-mode run: not possible in this environment (no backend stack, no Stripe). #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes (two new optional model settings, commented out; the Jev key is the existing `TYPESAFE_API_KEY`) - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**): none No data migration is required: the new fields are optional, and rows written before this change read as Otto-owned with no `why`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01G58r45FBHQQQwJGu1SgbxC --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Krzysztof Czerwinski <[email protected]> |
||
|
|
6574f3a118 |
feat(backend): warn canceled trials 3 days and 1 day before access ends (#16506)
### Why / What / How **Why:** A trialist who cancels gets the cancellation confirmation, then nothing until "Your AutoGPT trial has ended", when access is already gone. Stripe does fire `customer.subscription.trial_will_end` for canceled trials (17 of the last 40 live events), but the ending reminder dropped them as "cancellation is scheduled". Closes [SECRT-3324](https://linear.app/autogpt/issue/SECRT-3324). **What:** Two new trial notices for trials set not to renew, each with **Resume my trial** as its one action: | Kind | When | Subject | |---|---|---| | `expiring` | 3 days before the end (Stripe's `trial_will_end`) | Your AutoGPT trial ends in 3 days | | `last_call` | 18 to 25 hours before the end (new hourly pass) | Your AutoGPT access ends tomorrow | **How:** Both are ordinary `TRIAL_UPDATE` kinds, so they reuse the trial notice pipeline: the ownership and checkout-attempt checks, a dedupe claim per trial end date, the freshness check at delivery, and the billing sender, kit and footer. When `trial_will_end` arrives for a cancel-pending trial, it now becomes `expiring` instead of being dropped. The scheduler runs `send_trial_last_calls` at :15 every hour, in the database manager like the Stripe tier sweep. The pass lists running, unconverted, cancel-pending trials whose end falls in the window, and calls `notify_trial(..., "last_call")` for each. If one trial fails, the pass logs it and carries on. These are service messages: like the other trial notices, they go out even to people who opted out of product news. So they stick to facts about the person's own account, with no "Last chance" framing or urgency styling. Persuasion belongs to the MailerLite win-back sequence. ### Journey (7-day trial, canceled on day 2) Day 2: cancellation confirmed → day 4: `expiring` → day 6: `last_call` → day 7: trial ended. - `ending` ("you'll be charged") and `expiring`/`last_call` are mutually exclusive on `cancel_at_period_end`, so nobody gets both kinds for one trial end. - Each new notice goes at most once per trial end. Cancel → resume → cancel again does not resend it. - Neither goes to someone who canceled in the last day (Stripe's `canceled_at`): they have just had the cancellation notice, with the same date and the same way back. - A resume, a conversion or another change before delivery suppresses a queued notice, as for the other trial notices. - No MailerLite group change: the trial group already dropped them when they canceled. ### Before / after **Before:** a canceled trialist got no email at either point. The 3-day `trial_will_end` event was logged as `Dropped trial ending notice for user …: cancellation is scheduled`, and there was no 1-day email at all. **After** (rendered from this branch's templates with fixture data, Pro at the live trial price of $50.00 / month): | 3 days before | Last day | |---|---| | <img src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/expiring-phone.png" width="300" alt="Your AutoGPT trial ends in 3 days"> | <img src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/last_call-phone.png" width="300" alt="Your AutoGPT access ends tomorrow"> | Desktop: [3 days before](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/expiring-desktop.png) · [last day](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/last_call-desktop.png) Layout: a short lede, then the plan panel (plan, access end, auto-renew off), then two option cards, "If you resume" (with the price) and "If you do nothing", then the button with the cancel-again and tax note. All text meets WCAG AA contrast: labels and notes #575072 on #F5F3FC (6.8:1) and on white (7.5:1), body #2D2A3E on #F5F3FC (12.6:1), button text white on #6144DF (6.2:1). The plain-text part carries the same content in the same order. ### Found and left alone - **MailerLite overlap:** the live "Canceled trials resurrection" automation sends "Trial cancelled, but you can still use it" on trial day 5 or 6 to canceled trialists in the marketing audience. Once this ships, it lands between the 3-day and last-day emails. This PR doesn't change it; that's a separate decision. - In the plain-text version of the other trial emails, the CTA link runs into the "You received this…" line. Only the two new kinds are laid out cleanly here. ### Changes 🏗️ - `notifications/trial.py`: the `expiring` and `last_call` kinds, their windows, the recent-cancel rule, and `send_trial_last_calls`. - `data/subscription_trial.py` + `db_manager.py`: `list_trials_ending_unrenewed`, and `send_trial_last_calls` exposed over RPC. - `executor/scheduler.py`: the hourly `send_trial_last_calls` job. - Trial templates (HTML, text, subject): the layout above. - PostHog `trial_expiring` and `trial_last_call` events, sent on enqueue like the other trial events and pinned in `posthog_events_test`. The tracking plan and the email design doc are updated. - Tests cover rendering (including plain-text layout and no urgency styling), which reminder applies, the windows, the recent-cancel skip, claim keys, converted trials, the pass's error isolation, and job registration. `trial_audience_cancel_test`'s "no ending reminder while pending" test now expects the `expiring` notice, since this ticket changes that behaviour on purpose. ### Agents and large language models used Claude Code with Claude Opus 5.5. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Trial, design-system, approval-copy, freshness and PostHog event tests pass locally (258), plus the scheduler registration test - [x] Rendered both emails at phone and desktop width and checked contrast - [ ] CI green - [ ] After deploy: a canceled trial with under 4 days left gets the 3-day email on its next `trial_will_end`, and the last-day email on the hourly pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
f5f05f638c |
feat(platform): make background subtasks real work that returns to the chat (#16582)
### Why / What / How [SECRT-2813](https://linear.app/autogpt/issue/SECRT-2813), second increment. #15733 woke Otto after a background subtask for a bounded, tool-free summary: the child had ten minutes, the follow-up two, delivery a ten-minute window, and the result was pasted into the wake prompt. Nick's call on review: time is not a budget, and the wake should return to the main thread rather than deliver a summary. This makes background work real. **What changes for the user:** a background subtask runs until it is done, and when it finishes, fails or is stopped, Otto carries on in the original chat with everything it had, as soon as that chat is idle, however long that takes. No deadline, no delivery window, no expiry notice. **How:** - The child is spawned like a foreground subtask: the launching turn's tools minus the descent denials, its budget tree, cost attribution, credential pins, route and scope, and it may hand work on within the tree's depth. It keeps no deadline of its own; only a deadline the launching turn already had is enforced (`background_timeout` returns `None` otherwise). - The wake runs as a continuation of the launching turn under that turn's own envelope (same tools, spawn rights and spend ledger), as an unattended turn. It is governed by the user's allowance and spend limits the way every turn of that chat is, not by the subtask's node, so a spent subtask tree no longer blocks the chat from carrying on. - The wake message names the subtask and its outcome and points at `get_sub_session_result`. What the subtask produced stays in its own chat and reaches the model as a tool result, the way a foreground subtask's does, instead of as text inside a user message. The `result` column and the row-selection query go away; the UI keeps the short labeled notice. - Delivery waits for an idle parent indefinitely; the "follow-up window expired" notice is gone. Recovery still records a delivered continuation from Redis and records an interrupted child as failed when its chat went idle without an outcome. A child (or continuation) whose worker died is released by the sweep with the orphan rule the chat routes already apply: still running its claimed turn, no live stream, untouched for thirty seconds. That is a liveness check, not a work deadline, and it is now shared as `ORPHAN_RUNNING_RESET_SECONDS`. - A cancelled turn, a chat's or a child's, cancels the background work it launched and suppresses those wakes, so stopping a chat reaches grandchildren now that a child may delegate. - When the child's turn ends, the record notes which row holds its answer (`resultSequence`, nullable, on this PR's migration, backfilled for any task completed before it): every row after the launch is the turn's, a clarification it drained mid-turn included, when the worker still holds the turn or when the chat records no later turn (`_active_turn_id`); only once a later turn ran does recovery stop at the next user row. `get_sub_session_result` asks the record for every sub (one indexed read) and, for a background child, returns that turn's answer and tool calls, so a later turn in the child's chat is never handed back as the subtask's result, with the child's workspace files listed as for a foreground sub but only those written in the turn's window, from its launch to the next user message after its answer (a browser screenshot is saved for the child by another tool, so the turn's tool calls alone would miss it, while a later turn's files stay out); until the outcome is recorded it reports the child as still running rather than reading a partial transcript as a result. - A child still queued past a deadline it inherited is closed as timed out by the sweep (pickup would refuse it anyway); a child with no inherited deadline waits as long as it takes. - A launching turn whose allow-list leaves out `get_sub_session_result` is refused background work at launch (its continuation could not read the result), and the child session starts in the approval mode the parent's gate enforces, as the foreground path already does. - Cancelled or refused queued children give their node back; the continuation never used it. - `GET /sessions/{id}` reports `background_pending: bool` instead of `background_pending_until`; the tool output drops `background_expires_at`; the card polls a background subtask to its outcome rather than giving up at the foreground cap. - Migration: `deadline` becomes nullable and `result` is dropped (`20261104020000_background_subtasks_no_clock`). The flag is off and no rows exist, so the mixed-worker window carries no tasks. ### Changes 🏗️ - `background_task.py`, `background_subtasks.py`, `background_execution.py`, `background_delivery.py`, `background_task_db.py`, `turn_queue.py`, `db_manager.py`, `routes.py`, `run_sub_session.py`, `models.py`, `schema.prisma` + migration: the points above. Net about 530 lines smaller. - Frontend: `SubSessionLive`, `AgentCards`, `ChainRowView`, `backgroundSubtasks.ts`, the session mock helper and the regenerated `openapi.json`. - `docs/engineering/background-subtask-delivery.md` rewritten for the new behaviour; the harness investigation section is unchanged. ### Agents and large language models used Claude Fable 5.1 (Claude Code, in T3 Code). ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description. - [x] I have made and executed a test plan: - [x] Review round (Sentry, fourth): a child whose record waited for its own handoffs ends the way its last continuation ended, so a failed continuation reports a failed subtask rather than a completed one (the nested-handoff test runs both endings). - [x] Review round (CodeRabbit, fifth pass): the result tool reports a promotion refusal (a spent tree, say) from the child's chat ahead of the failed record the sweep writes (`test_result_tool_reports_why_a_background_child_was_refused`); a completed child without an answer row still bounds its file listing by its turn's window, and a record without a window lists nothing rather than everything; the window is applied in the workspace query itself, ahead of the 50-file manifest limit (`created_after`/`created_before` on `list_workspace_files` and `WorkspaceManager.list_files`, with a real-database test); and the sweep's orphan release leaves a chat alone while a worker still refreshes its session lock, whatever became of the stream metadata (`locked` case of the dead-worker test). - [x] Review round (Sentry, third): a child that hands work off itself no longer completes, and wakes the original chat, when its own turn ends with that work still out; its record waits for the last of that work to come back to it (`has_open_handoffs`), the sweep leaves such a child alone meanwhile, and the record completes with the child's latest answer when its last continuation ends (`test_a_child_that_handed_work_off_wakes_the_parent_once_that_work_is_back`). - [x] Review bot's second-round optional advice folded in: a real-database test for the sweep releasing a continuation whose worker died (`test_sweep_recovers_a_continuation_whose_worker_died`), and `_reconcile` split into settling the child's outcome and acting on it. - [x] Review round (CodeRabbit, fourth pass): the answer query reads the chat's active turn in the same statement as the rows (one snapshot, no window for a turn admitted in between); a recorded background result lists the child's workspace files from the authoritative listing, restricted to its turn's window so a later turn's stay out (`test_result_tool_lists_a_background_childs_workspace_files`, `test_the_sub_manifest_can_be_restricted_to_a_window`); the sweep re-arms the ledger key of every tree with open background work so its spend and node counts survive the wait, reopening being the fallback for a key lost anyway (`test_sweep_keeps_an_open_handoffs_tree_alive`); the migration states its conservative legacy rule instead of claiming to match recovery. - [x] Review bot's optional advice folded in: the sweep's orphan release fences on the task it already holds instead of re-reading it, the result tool's refusal branch has a test per terminal outcome, and the orphan threshold's rationale sits with its constant. - [x] Review round (CodeRabbit, third pass): the migration backfills `resultSequence` before dropping `result` (no such rows exist anywhere, the flag was never created, but the migration is now correct on its own); recovery uses the chat's recorded turn id to own the rows after a drained clarification unless a later turn ran (`test_answer_is_bounded_by_the_turn_the_worker_still_holds`, three endings). - [x] Review round (Sentry, second): a nested handoff can outlive the tree ledger's turn-lifetime key, so promotion and pickup of background work reopen a closed tree (`reopen_closed_tree`); a spent tree still refuses a nested continuation, which is the spend budget acting (`test_nested_continuation_reopens_an_expired_tree_but_not_a_spent_one`). - [x] Review round (CodeRabbit, second pass): the tool consults the record for every sub instead of the loaded message page, reports an unrecorded background child as running, and carries the recorded turn's tool calls so `sub_tool_call_count` and the file manifest are scoped to that turn (real-database tests). Its first finding, an empty intersection in `CopilotPermissions.merged_with_parent` reading back as allow-all, is pre-existing and untouched by this PR; flagged for its own fix. - [x] Review round (Sentry): a clarification drained mid-turn no longer hides the child's final answer; the answer row is recorded at completion with the worker-held bound, recovery keeps the conservative bound (`test_answer_is_bounded_by_the_turn_the_worker_still_holds`). - [x] Review round (GitHub Copilot's five findings): two overlap Capy's (dead-worker recovery, result-tool permission); a child still queued past an inherited deadline is now closed as timed out (new real-database test); the continuation-node refund is not reproducible (the continuation row is an answer row, so the queue never treats it as a spawned child); the six-hour staleness cutoff is the platform's bound on every turn, left as is. - [x] Review round (CodeRabbit's two findings): a launch without the result tool in the allow-list is refused, and the child inherits the parent's enforced approval mode; both with real-database tests. - [x] Review round (Capy's three findings, each with a real-database test): the sweep recovers a child whose worker died and leaves a live one alone; a cancelled child turn cancels and suppresses the grandchild it launched; the result tool returns the child's own answer when its chat has moved on. - [x] Real-Postgres/Redis handoff suite rewritten for the new rules: the wake runs under the launching turn's envelope and names the child instead of carrying its text; a spent subtask tree does not stop the chat; a cancelled queued child gives its node back; the child may delegate; the session state reports `pending`; recovery keeps a delivered continuation. The deadline, delivery-window and expiry-recovery tests are removed, and the inherited-deadline tests stay (`test_worker_enforces_an_inherited_deadline_even_during_silent_work`, the worker-clock test, the claimed-admission-failure test). - [x] In the Linux test image against a private Postgres/Redis/RabbitMQ stack: the background handoff, turn-queue, executor, session-waiter, sub-session, chat-route, scheduler and stream-registry suites pass (918 in the first run, the three old-envelope assertions then rewritten and the handoff suite re-run green). - [x] pyright 0 errors on the touched backend files; black/isort/ruff clean; private Prisma client regenerated for the schema change; OpenAPI export matches the committed `openapi.json`. - [x] Frontend: `tsc --noEmit` clean; eslint 0 errors (the four warnings are pre-existing); oxlint clean; the copilot lifecycle, ToolChain and helper Vitest suites pass, including the new cases that a background card keeps polling past the foreground cap and that the page polls while `background_pending` holds. - [ ] Smoke-test with a live provider: the review bot's sandbox cannot run Otto (revoked token); the behaviour is covered by the real-database handoff suite. #### For configuration changes: - [x] `.env.default` is already compatible. - [x] `docker-compose.yml` is already compatible. - [x] Configuration changes and rollout: apply the migration, deploy compatible API, database-manager, scheduler and workers, keep the flag off until then. --------- Co-authored-by: Claude Fable 5.1 <[email protected]> |
||
|
|
d981a641e3 |
refactor(frontend): remove the Tailwind palette; roles are the only colours (SECRT-3097 C4) (#16612)
### Why / What / How **Why:** SECRT-3097 follow-up, PR C of the theming plan (item C4). PRs A (#16511), B (#16513) and D (#16556) moved every app colour onto the role tokens, but Tailwind's palette was still compiled from `tailwind.v3-preset.ts`, so `bg-red-500` still worked and the "one colour system" goal was held only by lint. This PR removes the palette and locks the door. **What:** colour systems 4 → 1. The roles in `src/app/tokens/theme.css` (one `:root` role block, mapped by `globals.css`'s `@theme inline`) are now the only colours Tailwind knows. `tailwind.v3-preset.ts` is gone, `--color-*: initial` resets Tailwind's colours, and four colour lint rules are at error with a baseline of 0. **How:** - **Preset deleted, nothing else moves.** The preset was more than a palette: it carried all of Tailwind 3's theme (shadows, blur, breakpoints, z-index…) that keeps every class at its Tailwind 3 value. Those keys move verbatim into `tailwind.config.ts` as `TAILWIND_3_THEME`; only `colors` (and `APP_COLORS`/`withAppColors`) is dropped. - **Proved on the compiled CSS.** I compiled `globals.css` with the app's PostCSS config on `dev` and on this branch and diffed them rule by rule (then again on the two production builds' CSS bundles). Every removed rule either has an identical replacement or is listed under "Visual changes" below. - **Screenshots** of every page the scripted harness reaches (`scripts/screenshot-pages.ts`, threshold 0) at 1440×900 and 390×844, `dev` vs this branch. ### Changes 🏗️ **Palette removal** - Delete `tailwind.v3-preset.ts` (1,390 lines). Its non-colour Tailwind 3 theme moves into `tailwind.config.ts` unchanged; `presets` is gone. - `theme.css` starts with `@theme { --color-*: initial; }`, before any colour is registered, so a palette class generates nothing. - `bg-message-table-header` read `theme(colors.white)`/`theme(colors.zinc.50)`; now `surface`/`surface-muted` (same values). **Last palette uses, snapped or kept exact** - **User-picked palettes** (all already in `ds-colour-exempt.json`): the expert colour picker (`ColorStep/helpers.ts`), folder artwork (`FolderIcon.tsx`) and sticky notes (`StickyNoteBlock.tsx`). 225 palette classes become the exact same hex as arbitrary values (`bg-rose-300` → `bg-[#fda4af]`), so their CSS is byte-identical. The expert's saved id (`rose-300`) is unchanged. - **Two new roles** for status text on the always-dark fills (toast, terminal, tooltip), which replaced the last three per-class exemptions: - `success-inverse` 110 231 183 (emerald-300, same value): Text/Icon `success-light` tone. - `error-inverse` 242 105 105 (red-400, same value): terminal exit code, and the library card's error tooltip (was red-300, see below). - **Streamdown** (vendored chat markdown) still writes seven palette classes in its mermaid error box and image hover veil. A block at the end of `theme.css` draws them inside `:where([data-streamdown] *)` only, with the palette's values (a role where one is identical). Its seven shadcn alias names stay as before (role aliases, for its markup only, as the plan allows). - `KitSearchSkeleton` shimmer read `theme(colors.zinc.*)`; now `rgb(var(--color-surface-hover|surface-muted))` (same values). - `MultiSelect`: `scrollbar-thumb-muted-foreground` (a shadcn alias the rules did not see) → `scrollbar-thumb-foreground-tertiary` (same colour). - `.storybook/preview.tsx`: `bg-background` → `bg-canvas` (same colour). - The tour backdrop shader defaulted to `var(--color-sky-700)`, a variable that never existed at runtime, so the canvas fell back to black. It now reads `foreground-base` and turns a role triplet into `rgb()`. - `scrollbar-track|thumb|corner-*` read `transparent` from the palette (`scrollbar-track-transparent`, 17 uses); they now accept it as a keyword. The compiled-CSS diff caught this one. **Lint (all `error`, baseline 0, stories included, off only for tests and `__generated__`)** - `design-system/no-raw-palette-classes` (renamed from `no-palette-colours`). - `design-system/no-shadcn-alias-colours` (renamed from `no-alias-colours`). - `design-system/no-hex-colours` (unchanged; it now leaves a hex that is a class's whole arbitrary value to the rule below, so it isn't reported twice). - **New `design-system/no-arbitrary-colour`:** a colour class whose arbitrary value is a colour literal (`bg-[#..]`, `text-[rgb(..)]`, `border-[color:hsl(..)]/40`, `[color:#fff]`), with the matching role in the message. It reads `ds-colour-exempt.json`. A role through `var()` is allowed. Colours inside shadows and gradients are not in scope. The wrapped shadcn `no-arbitrary-values` now leaves arbitrary colours to it. - The colour rules (and the alias codemod) now also read `scrollbar-thumb|track|corner-*` classes. - No suppression comments used the old rule names. `design-system-baseline.json` is unchanged (it had no colour entries). **Tests** - New `src/components/styles/__tests__/roles.test.ts`: every role in `color-roles.ts` has its value in the one `:root` block of `theme.css` and an `@theme inline` entry. It compiles the real stylesheet: `bg-<role>` exists for every role, and `bg-red-500`, `bg-zinc-100`, `bg-white`, `bg-black` generate nothing. It also checks the reset comes before any colour is registered, the preset file is gone and the config has no `colors`/`presets`. - `tailwind3-compat.test.ts`: its palette fixtures become roles/arbitrary hex (same assertions), plus a "generates nothing" check for palette classes and a check for `scrollbar-track-transparent`. - `design-system-contract.test.ts`: "keeps the raw palette frozen" (it imported `APP_COLORS`) becomes "has no raw palette". - `design-system.test.ts`: renamed rules, `no-arbitrary-colour` cases, a scrollbar alias case. **Docs:** `docs/engineering/design-system.md`, `frontend/AGENTS.md`, `CONTRIBUTING.md`, and the comments in `globals.css`/`theme.css`. **Size:** +1,612 / −1,708 (3,320 lines, over the ~3,000 guide; kept as one PR as asked). About 2,320 of those are the preset: 1,390 deleted, 930 of them moved into `tailwind.config.ts` unchanged. **Counts (success criteria)** | Measure | Before | After | |---|---|---| | Colour systems | roles + compiled palette (+ alias names for streamdown) | roles only (streamdown's alias names point to roles) | | `tailwind.v3-preset.ts` | 1,390 lines | gone | | Palette rules in the compiled stylesheet | 198 | 0 (streamdown's 7 classes are drawn by a scoped block in `theme.css`) | | Palette uses in `src/` (outside test fixtures and the two stories that show raw classes on purpose, as iframe sample HTML and a "don't" code example) | 228 classes (225 in the three user-picked palettes, 3 on dark surfaces) + 7 palette references (`theme(colors.*)` ×3, `var(--color-sky-700)` ×2, and 2 in `tailwind.config.ts`) | 0 | | Colour entries in `design-system-baseline.json` | 0 | 0 | | Colour lint rules at error | 3 | 4 | ### Visual changes No layout or text moves. One small colour change is visible, so this PR is labelled `pixel-change`. There are **no bigger visual changes**. **Small (slight shade shifts)** 1. **Library, workflow card error tooltip** (hover a card whose last run failed): error text #F69999 → #F26969 (red-300 → red-400, now `error-inverse`, the same red the terminal's exit code uses). The page captures don't reach this hover state, so this is the real tooltip classes rendered with each build's CSS: <img src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/library-tooltip-error.png" width="700"> 2. **Tour (`/tour/chat`) backdrop dots:** dot colour #000000 → #141414 (`foreground-base`), drawn at ≤ 14% opacity. The darkest dot pixel goes from 231 to 232 (out of 255): not visible. Zoomed, left before, right after: <img src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/tour-dots-zoom.png" width="500"> **Unchanged by construction (identical compiled CSS), not reachable by the scripted capture:** the expert colour picker on `/raise` (later step), sticky notes in the builder, toast/terminal green text, terminal exit code, streamdown's mermaid error box and image veil, and the scrollbars. **Page screenshots** (`dev` vs this branch, both production builds with `NEXT_PUBLIC_PW_TEST=true`, signed-in user with one library folder; 32 captures, 25 identical at threshold 0). The other 7 are noise that has nothing to do with this PR: comparing two captures of this same branch gives the same diffs. | Page | 1440×900 | 390×844 | Changed px (1440 / 390) | Why | |---|---|---|---|---| | `/build` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__build__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__build__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__build__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__build__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__build__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__build__mobile.png) | 0 / 0 | identical | | `/copilot` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__copilot__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__copilot__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__copilot__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__copilot__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__copilot__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__copilot__mobile.png) | 756 / 686 | Otto avatar only: it is randomised per load (same diff, 783 px, between two captures of this branch) | | `/home` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__home__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__home__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__home__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__home__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__home__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__home__mobile.png) | 0 / 714 | Otto avatar only (randomised per load, as above) | | `/library` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__library__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__library__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__library__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__library__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__library__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__library__mobile.png) | 0 / 0 | identical | | `/login` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__login__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__login__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__login__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__login__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__login__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__login__mobile.png) | 0 / 0 | identical | | `/marketplace` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__marketplace__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__marketplace__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__marketplace__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__marketplace__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__marketplace__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__marketplace__mobile.png) | 0 / 0 | identical | | `/raise` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__raise__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__raise__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__raise__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__raise__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__raise__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__raise__mobile.png) | 41909 / 1957 | live animated shader; never holds still on either build | | `/reset-password` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__reset-password__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__reset-password__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__reset-password__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__reset-password__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__reset-password__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__reset-password__mobile.png) | 0 / 0 | identical | | `/settings/account` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_account__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_account__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_account__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_account__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_account__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_account__mobile.png) | 0 / 0 | identical | | `/settings/api-keys` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_api-keys__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_api-keys__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_api-keys__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_api-keys__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_api-keys__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_api-keys__mobile.png) | 0 / 0 | identical | | `/settings/integrations` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_integrations__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_integrations__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_integrations__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_integrations__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_integrations__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_integrations__mobile.png) | 0 / 0 | identical | | `/settings/profile` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_profile__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_profile__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_profile__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_profile__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_profile__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_profile__mobile.png) | 0 / 0 | identical | | `/signup` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__signup__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__signup__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__signup__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__signup__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__signup__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__signup__mobile.png) | 0 / 0 | identical | | `/team` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team__mobile.png) | 0 / 0 | identical | | `/team/autopilot` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team_autopilot__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team_autopilot__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team_autopilot__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team_autopilot__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team_autopilot__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team_autopilot__mobile.png) | 0 / 0 | identical | | `/tour/chat` | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__tour_chat__desktop.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__tour_chat__desktop.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__tour_chat__desktop.png) | [before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__tour_chat__mobile.png) · [after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__tour_chat__mobile.png) · [diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__tour_chat__mobile.png) | 8000 / 984 | backdrop dots are placed with `Math.random()` (7,242 px between two captures of this branch); dot colour itself moves ≤ 1/255 | Library with the folder artwork (exact hex now), before / after: <img src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/before/app__library__desktop.png" width="420"> <img src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/after/app__library__desktop.png" width="420"> ### Agents and large language models used - Claude Code with Claude Opus 5.5 (`claude-opus-5-5`) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `pnpm format`, `pnpm lint` (design-system rules at error), `pnpm types`: pass - [x] `pnpm test:unit`: 13,149 passed. 5 tests in unrelated files (admin users, copilot mode selector, onboarding intro card, suggested tasks, team autopilot) timed out while the machine was overloaded; all 5 files pass on a re-run (75/75) - [x] Compiled `globals.css` on `dev` and on this branch and diffed every rule (and again on both production builds' CSS bundles): only the changes listed above - [x] Before/after screenshots of 16 pages at 1440 and 390 with `scripts/screenshot-pages.ts`, compared at threshold 0 - [x] `bg-red-500`, `text-zinc-600`, `bg-white`, `bg-black` compile to nothing (`roles.test.ts`, `tailwind3-compat.test.ts`) #### For frontend changes (design system, see `docs/engineering/design-system.md`): - [x] `pnpm lint` passes, including the `design-system/*` rules; I did not raise any count in `scripts/eslint-rules/design-system-baseline.json` - [x] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`) or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens (exempt user-picked palettes keep exact hex through `ds-colour-exempt.json`) - [x] No imports from `src/components/__legacy__`, `legacy/` paths or `@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants - [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get a `variant`/`size` (no copy or markup changes here) - [x] Before/after screenshots at 1440 and 390 px are attached - [x] User-facing copy says workflow and Otto, never agent or graph (no copy changes) #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**): `tailwind.config.ts` and `.oxlintrc.json` only 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
3a043ddc3c |
fix(backend): a run's output no longer deletes an earlier output with the same file name (#16343)
When two workflow runs store an output file under the same name, the second is now stored beside the first as `output (1).mp4`. Before, it deleted the first, and the first run's link went dead. Resolves #16341 ### Why / What / How **Why:** A block that returns a file (a video from a Replicate model, a generated image) stores it in the user's workspace at `/<filename>`. When a later run produced a file with the same name, it deleted the earlier run's file: the earlier output link answered 404 and the result was gone. Some models give every output the same name (`fictions-ai/autocaption` always returns `output.mp4`), so each run destroyed the last one's result. The same happened inside one chat, where two block runs saving one name under `/sessions/<id>/` replaced each other. **What:** A stored block output never replaces an existing file. When its name is taken, it is stored beside the existing file as `output (1).mp4`, `output (2).mp4` and so on, the way a browser names a second download, and every earlier `workspace://` reference keeps resolving to its own bytes. Writes that ask to overwrite are unchanged: an upload with `overwrite`, the `write_workspace_file` tool's `overwrite`, a same-named re-upload in a chat, and Otto's own working files. **How:** `store_media_file` called `WorkspaceManager.write_file(overwrite=True)`, which replaced the file already at the path, retiring its row and deleting its blob. It now passes a new `rename_on_conflict=True` instead. `write_file` looks the path up once. When the path is taken, one query lists the numbered `name (` siblings and the write takes the next number. If a concurrent write takes that number first, the index refuses the insert and the write moves on to the next one (two retries, as `overwrite` has). A renamed write replaces nothing, so it hands the insert's storage-quota transaction no file to retire. The number goes on the name as well as the path, because the Files page and downloads show the name. A folder per run would have left several rows all reading `output.mp4`. Each run's output now stays until someone deletes it, so it counts against the storage quota; before, the overwrite freed the previous copy. ### Changes 🏗️ - `backend/util/workspace.py`: `write_file(rename_on_conflict=...)`, and `_free_path`, which picks the next free `name (n).ext`. - `backend/util/file.py`: `store_media_file` stores block outputs with `rename_on_conflict=True` instead of `overwrite=True`. - `backend/util/file_db_test.py` (new): three runs store `output.mp4` through the real `store_media_file` and the database, and each keeps its own bytes, as `output.mp4`, `output (1).mp4` and `output (2).mp4`; and a run whose free name another write takes between the look and the insert lands at the next name, through the storage-quota transaction, while the other write's file stays live. - `backend/util/workspace_test.py`: a write that loses the free name to a concurrent write takes the next number and deletes nothing, and one whose path is free keeps the caller's file name. - `docs/engineering/workspace-media-architecture.md`: the new parameter and the rule. ### Verified On dev's code `file_db_test.py` fails because the first run's file id no longer resolves (`FileNotFoundError`); with this change it passes. Five mutations each turn a test red: `overwrite=True` restored in `store_media_file`; the rename dropped from the lost-race retry, after which the real-database test shows the other write's file retired; the free-name search always answering `(1)`; a renamed write looking up a file to replace; and a free path renaming the file anyway. Every test module that imports `backend.util.workspace` or `backend.util.file`, or names `WorkspaceManager` or `store_media_file`, passes (51 modules behind one stack-starter test: 1,530 passed, 12 xfailed), apart from `api/features/store/skill_db_test.py`, whose guard refuses this machine's shared database and which runs on a fake workspace manager; `util/architecture*_test.py` (92) and `blocks/test/test_block.py` (3,738, 84 skipped) pass too. Pyright reports nothing on the changed files. I did not run a workflow end to end on a live stack. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Three runs storing one filename keep three files, each readable by its own id (real database) - [x] A write that loses the free name to a concurrent write takes the next number and deletes nothing - [x] Workspace, file, architecture and block suites pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]> |
||
|
|
b3d680f628 |
refactor(frontend): collapse colours to one role token set (SECRT-3154) (#16511)
### Why / What / How **Why.** The app named colours four ways: 47 role tokens, 27 shadcn alias names (`bg-background`, `text-muted-foreground`, `border-border`, …, 545 uses), the Tailwind palette (`text-orange-600`, `bg-gray-100`, ~400 uses) and hex literals (~900). Changing the theme could not change the whole app, and a dark theme would have to be written four times. This is PR A of the SECRT-3097 follow-up (SECRT-3154, plan items C1+C2+C3): one colour system, the roles. **What.** Every colour in `src/app`, `src/components` and `src/lib` is now a role class (`bg-surface`, `text-orange-foreground`, `ring-sky/30`) or, in a string, `rgb(var(--color-<role>))`. The raw values live in one `:root` block in `src/app/tokens/theme.css`, registered by `globals.css`'s `@theme inline` (the T3 Code layout: a later theme is a block of values, no component edits). The alias names are gone from the theme, and three lint rules at error with a baseline of 0 keep the other systems out. Artwork, logos, chart series and user-picked palettes are listed in `ds-colour-exempt.json` with a reason. **How.** - **Aliases (C1):** `scripts/ds/alias-to-role.ts` renamed each alias class to the role it already aliased (545 renames, 182 files), keeping variants, `!` and opacity, plus `var(--color-<alias>)` in inline styles. Because each alias rendered its role, this moves no pixel: `scripts/check-token-equivalence.ts --base <dev worktree>`, extended with the 27 alias→role pairs, reports **123 pairs, 0 failing; 1,785 opacity swatches, 0 differing pixels**. streamdown's prebuilt markup (scanned from `node_modules`) still uses seven shadcn names, so those seven are defined in `theme.css`, each aliasing a role; app code cannot use them (`no-alias-colours`). - **Palette and hex (C2+C3):** `scripts/ds/palette-to-role.ts` applied the reviewed table `scripts/ds/palette-map.json` (396 swaps): each palette step names its role as text, fill and line, and says whether the colour stays the same (`none`, 217 swaps), shifts a shade within its hue (`slight`, 176) or changes hue (`bigger`, 3). Hex in UI code became role variables by hand (xyflow edge arrow, tutorial icons, drag previews, the block-menu drag image, a banner hover, loader keyframes, chat-table glows, shadows). - **New roles, only where repeated uses needed a colour no role had:** `orange`, `orange-foreground`, `yellow`, `yellow-foreground`, `sky`, `sky-foreground`, `indigo`, `pink`, `pink-foreground` (usage meters, data types, category accents, admin diagnostics, run badges), and `scrim` (the black behind dialogs and in shadows, which must stay dark in any theme). Pale surfaces and borders of a hue are the role at an opacity rather than more roles: `bg-orange/10` is exactly orange-50, `bg-pink/10` exactly pink-50, and T3 derives its `-surface`s the same way, so a dark theme gets them for free. Everything else snapped to an existing role (rose → error, sky-50 → info-surface, yellow-50 → warning-surface, teal → success/sky). - **Lint:** `design-system/no-alias-colours`, `no-palette-colours` and `no-hex-colours` (error, all of `src/**` including stories, ratcheted at 0). Each error names the replacement (the aliased role, or the `palette-map.json` row). `ds-colour-exempt.json` is the only escape hatch: `scripts/__tests__/colour-exempt.test.ts` fails a suppression comment for these rules, an entry that matches no file, an allowed value the file no longer uses, and any hex in a stylesheet other than `theme.css`. `no-raw-colors` reads the same list and leaves palette and hex to the new rules, so nothing is reported twice. 120 `no-raw-colors` suppressions written for "this hue has no role" are gone. - Dead code found on the way: the builder's `hexColor` for output handles was passed to `<Icon color>`, which the handle's `text-handle` class always overrode; it is removed (no pixel change). ### Changes 🏗️ - `src/app/tokens/theme.css` (new): the one `:root` block of colour roles (57, 10 new), the `.dark` smooth-ring override that already existed, and streamdown's seven alias names. - `src/app/globals.css`: imports the token file; `@theme inline` registers the roles only (27 alias registrations removed); remaining raw colours (default ring colour, glows, loader keyframes, shadow) now read roles. - 300 call-site files: alias and palette classes swapped for roles (mechanical; see the scripts). No Text/Icon/Button atom internals changed beyond colour-token swaps (`compact-variants.ts`; the shared `styles/tones.ts` from #16513 keeps its `success-light` emerald, now exempt there). - `scripts/ds/` (new): `alias-to-role.ts`, `palette-to-role.ts`, `palette-map.json` (the reviewed table), `colour-exempt.ts`, `colour-utilities.ts`. - `ds-colour-exempt.json` (new): 31 entries, each with a reason. Whole files: the token file, the vendored Tailwind 3 reset, Notion avatar artwork, empty-state illustrations, the AutoGPT logo, the glass orb, confetti, the dither shader, Otto's avatar aura and greeting orb, the memory-graph canvas, recharts charts (SVG attributes, where CSS variables do not resolve), the React-artifact iframe document, the MCP OAuth callback page, the tour's scripted documents (support-ticket numbers that read as hex), sample artifacts in two stories, and three user-picked palettes (expert colour picker, folder colours, sticky notes). Single values: chart series in the expert overview and execution analytics, the avatar fallback palette, the onboarding cover's baked-in backdrop, a spinner mask, the code block's dark fallback, `text-emerald-300` / `text-red-300` / `text-red-400` on always-dark surfaces (the error and success roles drop below contrast there until the dark theme gives them dark values), and the overview story's "don't" examples. - `scripts/eslint-rules/design-system.mts`, `.oxlintrc.json`: the three rules; role advice now reads `theme.css`; `no-raw-colors` defers palette/hex and honours the exempt list. Baseline: `styles/tones.ts` `no-raw-colors` 1 → 0, nothing raised. - Tests: `colour-exempt.test.ts` (new); patch-coverage tests for the changed JSON renderer, usage bar, execution-path badge and builder type colours; `design-system.test.ts` (rule cases), `theme-aliases.test.ts` (only streamdown's names remain, each renders its role; dropped names generate nothing), `design-system-contract.test.ts` (reads `theme.css`), `tailwind3-compat.test.ts` (ring default reads `--color-info`), `edgeStore.test.ts` (marker colour). No test deleted. - `src/components/styles/color-roles.ts` + Tokens/Colors story: the new Accents group and `scrim`; the "shadcn names" story is removed with the names. - `scripts/check-token-equivalence.ts`: the alias→role pairs, so it proves the rename against any base. - Docs: `docs/engineering/design-system.md` (Tokens section) and `frontend/AGENTS.md` (Taste → Colour). - Not in this PR: `tailwind.v3-preset.ts` is untouched (PR C deletes it; streamdown's red classes and the exempt user palettes still need it until then), no dark values, `forcedTheme` unchanged, no theme switcher, no Storybook toggle. CODEOWNERS is unchanged (it is admin-owned); `src/app/tokens/`, `ds-colour-exempt.json` and `scripts/ds/` could join the design-system block there. - Size: 315 files, +2,932/−1,320 (4,252 lines), over the ~3,000 guide; 776 of those lines are the reviewed `palette-map.json` table and most of the rest are one-token swaps. Kept as one PR as asked. ### Visual changes Refreshed after merging `dev` |
||
|
|
8fa5a699b2 |
feat(platform): wake Otto after opt-in background subtasks (#15733)
### Why / What / How [SECRT-2813](https://linear.app/autogpt/issue/SECRT-2813): background helpers were lost when the parent turn closed its SDK client. #15127 made native helpers foreground-only; that safeguard remains in place. This adds explicit `run_sub_session(background=true)`. A fresh subtask runs through the durable queue after Otto finishes its initial reply. Completion, failure, cancellation, or timeout wakes the original chat for one **budgeted, tool-free summary**. Foreground remains the default. A durable handoff record tracks acceptance, outcome, and follow-up admission. Stable IDs and execution claims prevent duplicate model runs; scheduler reconciliation recovers missed dispatch and delivery. Both stages retain the original budget tree, billing context, credentials, owner, and organization/team/expert scope. ### Changes 🏗️ - Add `copilot-background-subtasks`, **off by default**, and an additive `ChatBackgroundTask` migration. No new service or environment variable. - Bound the child to 10 minutes including queue time and its follow-up to 2 minutes, without extending an inherited deadline. Recheck the original budget and permissions at promotion and pickup, including block restrictions. Child and summary share one admitted background node. Existing spend limits meter settled usage; they **do not reserve concurrent spend or guarantee zero overshoot**. - Show pending status, a subtask link, and Stop. The session endpoint reports `background_outcome` for a child chat and `background_pending_until` for a parent chat from the task record, so cards and the parent's late-reply polling do not depend on the launch or acceptance rows staying within the 50-row transcript page. Cards poll until that outcome lands, with unknown status at expiry if needed. Both chat runtimes refresh for late replies; missing message history during session refresh safely disables polling, and unrelated workflow-result or skipped-run notices do not stop a pending follow-up refresh. Synthetic turns have a readable label and stay out of user-message analytics. - Child Stop reports cancellation; stopping the launching parent turn also suppresses its automatic wakes. Stopping a queued follow-up atomically saves one cancellation notice and releases the session, including when later user messages have been persisted. Saved execution identity and atomic, scoped cleanup protect successor turns even after Redis loss. Deleted handoffs release only their own queued turn; admission clears the previous turn ID before a successor can be dispatched. Execution identity remains separate from a shared analytics trace. - Recover delivery by saved wake ID. Busy chats defer delivery and receive a terminal notice if the delivery window expires. Recovery preserves a follow-up when queue cancellation loses to promotion or newer work. A connection change before delivery produces one static notice at the next idle opportunity; scope changes still suppress delivery. The scheduler holds its single-job slot until reconciliation finishes, preventing overlapping sweeps. - Recover completed child work, and a delivered follow-up, after a failed outcome write only from a matching owner/claimed-turn success record; preserve committed cancellation and reject missing or mismatched evidence. The worker that still holds a child's turn takes the answer after a clarification it drained mid-turn; recovery stops at the next user row. Distinguish cancellation/deadline/failure notices, and invalidate the parent chat cache after notice commit. A handled execution deadline finishes normally at the queue boundary; operation and cleanup errors still propagate. Duplicate pickup leaves the active turn intact. Admission refusals close with the actual reason; unreadable background limits retain the node for its summary, and background refusals omit approval-answer copy. - Fence dispatch cleanup by turn ID; admission clears the recorded turn identity, so one admission records one turn and a dispatcher that stalled past orphan recovery refuses itself instead of overwriting its successor. A scheduler stop names the turn it expires, and the worker ignores a stop whose turn is no longer running. A new HTTP send that loses admission returns a retryable 409 before saving the message; a racing double send of the same text still rides the first send's turn, as dev's #15918 made it. Scheduled one-shots retry busy chats within existing limits; recurring schedules keep their next tick. Preserve dev's scheduled-chat failure tracking and tracing. - Document harness findings, lifecycle, budgets, and rollout in [the design note](docs/engineering/background-subtask-delivery.md). Rollout: apply the migration, deploy compatible API/database-manager/scheduler/workers, then enable the flag for test accounts. Keep it off while workers are mixed. Disabling it prevents new opt-ins while accepted work drains. Review focus: durable handoff and cancellation races, original scope/budget preservation, and the pending/Stop/follow-up experience. ### Agents and large language models used Codex with gpt-6-astra, running in T3 Code. Claude Fable 5.1 (Claude Code, in T3 Code) for the dev merge, its validation and the review rounds after 2026-11-03. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description. - [x] I have made and executed a test plan: - [x] Reproduce native background loss using the locked Claude SDK (0.2.161), installed CLI (2.1.303), fake credentials, and a local scripted Anthropic server; verify the existing foreground regression. - [x] Exercise 74 real Postgres/Redis handoff scenarios: parent finishes first, single follow-up, duplicate pickup, busy parent, budgets, Stop, timeout, lost metadata, owner/route rejection, and successor protection. Reproduce expiry/promotion and cascade-deletion races before their fixes, including missing Redis metadata and the admission-before-dispatch window. All 396 focused handoff/queue/background/admission/chat-route cases pass, including 74 real-database scenarios. Queued Stop reproduces the lost cancellation reason before its fix; new controls cover later pending messages, ownership, promotion, notice-write rollback, idempotency, and cache visibility. Three existing real-database concurrent-cap/status-CAS cases also pass. - [x] Pass 93 queue/background cases and 234 scheduler/async-bridge/reset cases. Reproduce malformed-envelope budget refunds, misleading background refusal copy, and a scheduler job returning while its coroutine still runs; verify their fixes and normal-child/approval/credential-sweep controls. - [x] Pass the focused worker/dispatch/scheduled-watch suites, 150 tracing/tool/sub-session cases, and 5 worker execution-context cases. Negative controls fail when promotion admission, worker admission, completion persistence, timeout enforcement, or retained summary budget is removed. - [x] Reproduce a handled deadline escaping the worker as `TimeoutError`; pass 32 timeout, Stop, shutdown, and manager cases. The manager acknowledges handled deadlines and user Stop, while real operation and cleanup timeouts remain rejected. - [x] Pass all 119 selected background lifecycle/worker cases on the latest patch, including 87 real Postgres/Redis scenarios. This includes the strengthened timeout outcome/session-release/single-notice assertions. Reproduce both route-change silence and successful work becoming failed/timed_out after an outcome-write failure; cover one notice, cache visibility, busy-parent deferral, scope rejection, matching completion recovery, and wrong-owner/turn, missing/failed/unclaimed record and cancellation controls. - [x] Reproduce both schema-size CI failures, then pass all 333 schema/sub-session/background cases on the branch and its prospective merge with dev `c31ec68f7d`. Trim only tool prose: registry 77,149 < 77,176; session wire 69,654 < 69,788. Existing budget limits are unchanged. - [x] Pass frontend card/history/chat-lifecycle coverage (82 cases) and notification/helper/chat-lifecycle coverage (102 cases), including late delivery in both runtimes, idle-child recovery, expiry, and declined Stop feedback. Reproduce the missing-history polling crash from frontend CI, then pass all 34 helper/lifecycle/kickoff cases, including omitted/null history with successful sends and replies in both runtimes. Reproduce six missed-refresh cases when standalone workflow-result or scheduled-run notices arrive before the follow-up; pass all 82 polling/lifecycle/history/kickoff cases with coverage after sharing the existing standalone-message classification. - [x] Validate the SQL migration transactionally; export OpenAPI and regenerate the frontend client. Use a temporary Prisma client and disposable, task-owned Postgres/Redis services. Cleanup is verified: no task-owned Postgres/Redis services remain after the latest successful run. - [x] Run frontend format/lint/types, focused backend pyright, Black/isort, and repository pre-commit checks including Ruff and secret scans. - [x] Smoke-test with a live provider and all deployed services: the `/dev-review` bot's local run on `1f0a1bc` (2026-10-23) sent a background request, saw the approval card ("Run in background: Yes"), got the parent's immediate reply, and received the follow-up turn "Background subtask completed. Otto is following up." / "17 + 25 = 42" after the parent turn closed, with the `ChatBackgroundTask` row at `outcome=completed`, `followupState=finished`. - [x] Merge dev (542 commits, 11 conflicting files) and re-validate on the merge: every PR backend module imports; pyright 0 errors on the 38 touched backend files; black/isort/ruff clean; the OpenAPI export matches the committed `openapi.json`. In the Linux test image against a private Postgres/Redis/RabbitMQ stack: background handoff, turn-queue, session-waiter, sub-session, executor utils/tracing/manager/processor, chat routes, scheduler and tool-base suites pass (batches of 308, 501 and the patched files). Frontend: `tsc --noEmit` clean, 944 copilot lifecycle/ToolChain/helper tests pass, prettier/eslint/oxlint clean on the touched files. - [x] Review round on the merge (GitHub Copilot's six findings, each with a test): session-level background state on `GET /sessions/{id}` with a DB test and a route test; turn-bound scheduler cancel with a manager test; the one-admission-one-record CAS, follow-up success recovery and worker-held result selection with real-DB tests. Re-ran the handoff, executor, route, queue, scheduler and sub-session suites in the Linux image, plus tsc, eslint and the copilot Vitest suites. - [x] Two dev-drift fixes on the merge, each with a test: dev's #15918 expects an identical double send on a refreshed slot to ride the first turn (the PR refused it with a 409), so `schedule_chat_turn` now collapses only an identical trailing text and refuses anything else before saving; and dev's `HandoffStatus` plus the React Compiler lint rules needed `useSubSessionEffectiveStatus` to return `string | null` and the poll cap to be keyed state instead of a setState inside the effect. Local limitations: the full repository suite was not run. Full backend types report four unrelated Windows/Prisma errors, and full-file Ruff findings in queue/processor code are unchanged. Focused types and pre-commit Ruff pass. Direct checks replace the local isort wrapper's unsupported Poetry option and the type hook that runs from the repository root. Spend overshoot from concurrent settled usage remains an existing budget limitation. #### For configuration changes: - [x] `.env.default` is already compatible. - [x] `docker-compose.yml` is already compatible. - [x] Configuration changes and rollout are listed above. --------- Co-authored-by: gpt-6-astra (Codex) <[email protected]> Co-authored-by: Claude Fable 5.1 <[email protected]> |
||
|
|
aba0ba606d |
refactor(frontend): one text-size scale; Tailwind's sizes reset (SECRT-3152) (#16556)
### Why / What / How
**Why:** SECRT-3097 follow-up, PR D of the theming plan (item T1,
SECRT-3152). The app had two type scales: Tailwind's
`text-xs`…`text-4xl` (about 1,050 uses) and the design-system steps the
`Text` atom uses (`caption`, `body`, `lead`, `heading`…). Sizes from the
first can't follow a theme or a redesign, and the two drifted (18px,
24px and 30px headings sat next to 20px, 22px and 28px ones).
**What:** the design-system scale is now the only text-size scale. Its
values live in one token file, Tailwind's own sizes are reset so
`text-sm` renders nothing, and a lint rule fails any raw or arbitrary
size at error with a baseline of 0.
**How:**
- **Tokens, T3 Code style.** `src/app/tokens/typography.css` holds one
`:root` block with every step's size and line height. `globals.css`'s
`@theme inline reference` maps each step to its `text-<step>` utility
(`--text-body: var(--text-body)`), and `--text-*: initial` drops
Tailwind's defaults. A redesign or a theme changes values in that one
file. This is a separate file from PR A's `tokens/theme.css`, so the two
PRs don't collide.
- **Reviewed mapping + codemod.** `scripts/ds/text-size-scale.ts` holds
the scale and the mapping table below. It also has a codemod that
rewrites class names inside string literals and template text only (via
the TypeScript AST, so comments are untouched). It keeps variant
prefixes and `!`. It rewrote 1,049 classes in 390 files.
- **Two new steps.** I added two steps so no size moves more than 4px
(except one emoji):
- `heading-md`, 24/32: Tailwind's `2xl`. Snapping it to `heading-sm`
22/24 would have cut every such line box by 8px.
- `hero`, 64/1: the tour hero. The scale topped out at 44px.
- **Elements kept as they are.** Converting ~1,000 raw `<p>`/`<span>`s
to `<Text>` would also force `font-sans`, `font-normal` and a default
colour onto copy that today inherits them from its parent. That is a
pixel risk unrelated to size. So the codemod swaps the size class in
place, and the Text variants that were named after Tailwind's scale were
renamed or merged (below).
| Tailwind | Step | Size / line height | Change |
|---|---|---|---|
| `text-xs` | `text-caption` | 12/16 → 12/18 | line +2px |
| `text-sm` | `text-body` | 14/20 → 14/22 | line +2px |
| `text-base` | `text-body-lg` | 16/24 → 16/26 | line +2px |
| `text-lg` | `text-lead` | 18/28 → 20/28 | size +2px |
| `text-xl` | `text-lead` | 20/28 | none |
| `text-2xl` | `text-heading-md` (new) | 24/32 | none |
| `text-3xl` | `text-heading` | 30/36 → 28/40 | size −2px, line +4px |
| `text-4xl` | `text-title` | 36/40 → 32/40 | size −4px |
| `text-5xl`…`9xl` | `text-display` / `text-hero` (new) | | only `7xl`
was used: 72 → 64px |
Text with an explicit `leading-*` keeps it, so its line height does not
move.
### Changes 🏗️
**Scale and tokens**
- New `src/app/tokens/typography.css`: 14 steps, tiny (10px) to hero
(64px).
- `globals.css`:
- `@import`s the token file;
- maps the steps in `@theme`;
- resets Tailwind's `--text-*`.
- `tw-merge.ts` knows `heading-md` and `hero`.
**Call sites:** 1,049 raw sizes → scale classes (390 files, stories and
tests included), plus these hand edits:
- **Text variants named after Tailwind's scale:**
- `lg-semibold` and `lg-medium` were now identical to `lead-semibold`
and `lead-medium`, so they were merged into them.
- `2xl`, `2xl-bold` and `2xl-bold-tracking-tight` were renamed to
`heading-md`, `heading-md-bold` and `heading-md-bold-tracking-tight`.
- **New `hero` Text variant:** `TourHero` uses it instead of a
suppressed `text-[4rem] leading-none` restyle.
- **`FileIllustration`:** the 5px/6px code glyphs (`{`, `:`, `</`) in
the file thumbnails are artwork far below the scale's 10px floor. They
are now drawn as SVG text in the exact box the text took (measured:
advance width × 1.5em, baseline at 1.1em), so the odd sizes are gone
without changing the thumbnails.
- **Dialog title:** dropped `text-md`, a class that never existed and
generated nothing.
- **Resolved TODOs:** removed the `TODO(tokens): SECRT-3152` comments
this PR settles (7 files).
**Streamdown:** the chat's markdown renderer, a dependency, writes
Tailwind's size names in its own markup (headings, table cells, inline
code, code-block header, download menus).
- `globals.css` gives those classes the same steps inside
`:where([data-streamdown], [data-streamdown] *)`.
- It weighs one class and sits before the utilities, so sizes the app
passes in still win, as before.
**Lint**
- New `design-system/no-raw-text-size`, at `error` for `src/**` (stories
and tests included, since a raw size renders nothing there either). It
is off only for `__generated__`.
- It reports named Tailwind sizes with any variant prefix, and arbitrary
lengths (`text-[13px]`, `text-[length:..]`, `text-[calc(..)]`). The
message names the step to use.
- The baseline has 0 entries for it.
**Tests and docs**
- `scripts/__tests__/type-scale.test.ts` compiles the real stylesheet
and checks:
- every step's utility reads its token;
- the token file holds exactly the scale;
- Tailwind's sizes generate nothing;
- the Streamdown shim uses the same steps.
- Rule tests in `design-system.test.ts`. `tailwind3-compat` and
`tw-merge` tests are updated to the new names.
- `docs/engineering/design-system.md`, Typography: the scale, the
mapping and the rule. The token stories show the new scale.
**Counts**
| Measure | Before | After |
|---|---|---|
| Type scales | 2 | 1 |
| Odd/arbitrary sizes | 10 distinct (5, 6, 10–16px, 4rem) | 0 |
| Raw `text-<size>` uses in code | ~1,050 | 0 (lint error, baseline 0) |
**Size:** +1,753 / −1,235 lines in 399 files, about 3,000 changed lines.
It stays one PR as agreed; the bulk is one-token class swaps from the
codemod.
### Visual changes
**Method**
- Playwright against `pnpm dev` of `dev` (
|
||
|
|
2ce23c2b44 |
refactor(frontend): Text, Icon and Button variants instead of call-site restyles (#16513)
### Why / What / How
**Why:** SECRT-3097 follow-up, PR B of the theming plan. Text, Icon and
Button were restyled at the call site about 1,700 times (colour, weight,
padding, radius and motion classes in `className`). Every one of those
is a look the primitive doesn't know about, so it can't follow a theme,
and copies of the same look drift apart.
**What:** the looks those call sites were reaching for are now props on
the atoms. The call sites use the props, and `className` is left for
layout. `design-system/no-restyle` now also checks Text, Icon and Button
inside `src/components/{atoms,molecules,organisms}`, where it used to be
off. The baseline is whatever is left.
**How:** the props are cva variants that use role tokens only, and the
colour values are shared by all three atoms
(`src/components/styles/tones.ts`). Call sites were moved in two ways:
- **A codemod** (not committed) that rewrote a site only when the merged
class set (twMerge, with alias colours resolved to their role and
size/line-height compared as computed values) came out identical.
- **Hand edits** for the rest, under one rule: the same rendering, or a
change of at most 4px or a slight shade shift. Every such change is
listed below.
No colour tokens were renamed or removed (PR A's job), and the type
scale is untouched (PR D's job).
### Changes 🏗️
**New props**
- **Text:** `weight`, `mono`, `tabular`, `italic`, `decoration`,
`transform`, `tracking` (token steps only), `align` and
`motion="colors"`. The existing `tone` gains `base`, `emphasis-soft`,
`inverse`, `brand`, `brand-strong`, `accent`, `error-strong`,
`warning-bright`, `info` and `info-strong`.
- **Icon:** `tone` (same values as Text), `spin`, and `motion`
(`transform` | `colors` | `opacity`). Icon only cn-merges `className`
when one of these props is set, so existing class strings pass through
untouched.
- **Button:**
- variants `ghost-muted`, `ghost-subtle`, `ghost-danger`,
`ghost-warning`, `brand`, `warning`, `success` (the "copied" state) and
`tray` (the chat composer's icon buttons);
- sizes `row` (32px), `inline` / `inline-xs` (no box, for a link inside
copy) and `icon-2xs` (24px);
- a `shape` prop (`pill` / `rounded` / `soft`) and a `tone` prop for
text colour;
- `compact-ghost` gains an `aria-pressed` fill.
- Stories (Text `Modifiers`; new Icon stories; Button `QuietVariants`,
`RowAndInlineSizes`, `Shapes`) and unit tests for every new prop.
`docs/engineering/design-system.md` is updated.
**Call sites:** 380 files. Besides the prop swaps:
- Box styling that sat on a `Text` (padding, fill, border) moved to a
wrapping element.
- Buttons that only fade in on row hover moved the reveal to a wrapping
`flex` span. `focus-visible` became `has-[:focus-visible]`, which
behaves the same.
- `DateTimeInput`'s trigger is now a plain `<button>` with the exact
classes it rendered before, like `DateInput`'s.
- `MessageBranchPage` no longer uses `ButtonGroupText`, which had no
other callers and was removed.
**Lint**
- `.oxlintrc.json` puts the Text/Icon/Button contracts into the
atoms/molecules/organisms override and lets every other component
through there.
- The Text contract also accepts `gap-*` and `text-ellipsis` (layout and
truncation).
- The baseline was regenerated:
- New entries are only those 20 newly-checked component files.
- The emerald-300 raw colour moved with the tones from `Text/helpers.ts`
to `styles/tones.ts`.
- No other count went up.
**Restyle counts:** Text/Icon/Button classes reported by `no-restyle`,
on the same tightened scope, counting suppressed ones too.
| | Before | After |
|---|---|---|
| Text | 585 | 80 |
| Icon | 515 | 43 |
| Button | 592 | 182 |
| **Total class uses** | **1,692** | **305** |
| Call sites | ~880 | ~150 |
The plan's target was under 150. The call-site count meets it; the
class-use count does not. What's left is mostly bespoke one-offs:
- split buttons;
- segmented controls;
- gradient text;
- responsive heading sizes;
- line-height overrides with no variant (PR D will fold those into the
type scale);
- group-hover colours whose state React doesn't know;
- raw palette colours (PR A).
**Size:** about 5,800 changed lines across 393 files, over the ~3,000
the plan expected. It stays one PR as agreed; the bulk is one-token prop
swaps.
### Visual changes
**Method**
- Playwright, against production builds of `dev` (
|
||
|
|
9e2a2c2846 |
feat(backend): remove Langfuse tracing of AutoPilot conversations (#16026)
### Why / What / How **Why:** AI observability moved to PostHog. Since the 23 Oct release, PostHog shows each AutoPilot reply as one trace with its tool steps (#15604, #15605) and every model call (OpenRouter Broadcast), so Langfuse tracing is a second copy of every conversation at another vendor. Product (Toran, 22 Oct) approved removing Langfuse entirely; this PR removes the tracing only. Prompt management stays in Langfuse until [SECRT-3022](https://linear.app/autogpt/issue/SECRT-3022) moves the prompts to PostHog. **What:** No path sends anything to Langfuse except the two prompt fetches. Ticket: [SECRT-2711](https://linear.app/autogpt/issue/SECRT-2711). **How:** The tracing code is deleted rather than switched off. The prompt fetches go through one Langfuse client built with `tracing_enabled=False` (`backend/util/langfuse_prompts.py`), and nothing else in the backend builds a Langfuse client or calls `langfuse.get_client()`, so the SDK never sets up a tracer or span exporter, whatever `LANGFUSE_TRACING_ENABLED` says. ### Changes 🏗️ Removed (tracing): - Baseline path: the `langfuse.openai` wrapper (main and aux clients are plain `openai.AsyncOpenAI` again), `propagate_attributes` and the OTel cost attributes on the turn span. - SDK path: `_setup_langfuse_otel` (LangSmith's Claude Agent SDK hook → OTLP → Langfuse), the Langfuse parent span per turn, and the `get_user_tier` lookup that only fed its metadata. - `copilot/sdk/trace_export.py` and its `opentelemetry_otlp_credential_provider` plugin entry in `pyproject.toml`; the `langsmith` dependency (lock drops `langsmith`, `uuid-utils`, `xxhash`). - `copilot/inference/trace.py`, the Langfuse trace around background LLM calls (briefing, dream, learning review, consult, style eval). The calls and their cost records are unchanged; `InferenceContext.trace_id` and the `langfuse_trace_id` cost-log metadata go with it. - The `openrouter-cost-reconcile` event written onto the Langfuse trace (the cost record itself is unchanged). - Ratings: no Langfuse score any more. Ratings are still saved in Postgres and sent to PostHog as `$ai_metric`, unchanged. `MessageFeedbackResponse.langfuse_target` is removed (the frontend never read it; openapi.json regenerated). - `scripts/replay_session_trace.py`, which replayed Langfuse traces. - The `langfuse_tracing_environment` setting. `ChatMessage.langfuseTraceId` and `ChatMessageFeedback.langfuseTraceId`: nothing reads them now, so they are no longer written. The columns stay; SECRT-3022 can drop them with the rest of Langfuse. Google data redaction (#15490): the Langfuse `mask` and the LangSmith `hide_inputs`/`hide_outputs` hooks are removed with the tracing. The rest of `copilot/google_data_redaction.py` stays, because other code still calls it: the Google call/output checks and remembering (`tools/base.py`, `gate/held.py`, `sdk/tool_adapter.py`), `GOOGLE_DATA_METADATA_KEY` (`tools/workspace_files.py`), `holds_google_data` and `tracing_google_data()`. Frontend replay masking is untouched. Kept as is: - Prompt fetching: `_fetch_langfuse_prompt` with the bounded-stale cache (`langfuse_prompt_name`, `langfuse_prompt_cache_ttl`) and the onboarding greeting prompt (`BRAIN_DUMP_GREETING_PROMPT_NAME`). - Our PostHog traces and spans (`ai_spans.py`, #15604/#15605) and the OpenRouter Broadcast attribution (`llm_attribution.py`, `x-session-id`/`x-user-id` headers). Docs: `.env.default` says the Langfuse keys are for prompts only; `docs/engineering/tracking-plan.md` and code comments no longer mention a Langfuse score or trace. Config: - Still used: `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_HOST` (prompt fetches only). - No longer used, safe to remove from deployment secrets/config later: `LANGFUSE_TRACING_ENVIRONMENT`, and any `LANGSMITH_*` or `OTEL_*` variables (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_PYTHON_EXPORTER_OTLP_HTTP_TRACES_CREDENTIAL_PROVIDER`) if they were set by hand. The code used to set those at startup itself. Outside this repo, not done here: - Turn off the Langfuse destination in OpenRouter's Broadcast settings, if it is on (PostHog stays). - Decide whether to delete the traces already in Langfuse or set a retention period. ### Agents and large language models used - Claude Code — Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] New `backend/util/langfuse_prompts_test.py`: the prompt client is built from settings with tracing off, sets up no tracer even with `LANGFUSE_TRACING_ENABLED=true`, and is built once - [x] Unit tests for changed modules pass locally (feedback routes and service, prompt cache, onboarding greeting, Google data redaction, inference record/context, briefing, dream orchestrator, consult, OpenRouter cost, SDK service helpers and retry scenarios, db/model stamps); service-backed tests left to CI - [x] ruff/isort/black and pyright on changed files; frontend `pnpm types`, prettier and the `AssistantMessageActions` test - [ ] After deploy to dev: AutoPilot replies still use the Langfuse system prompt, and no new traces appear in Langfuse #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
3c06724a3e |
refactor(platform): remove LaunchDarkly and read every flag from PostHog (#15853)
### Why / What / How **Why:** LaunchDarkly gets users' email, email domain, role and signup date, without consent. Dev and prod have served every flag from PostHog since 16 Oct (infra #437/#439), and #15583 made PostHog the code default, so LaunchDarkly no longer answers any flag. Removing it closes gap 3 of the data-flow audit and drops a vendor. The team decided on 23 Sep to remove it entirely, experiments included. Ticket: [SECRT-2708](https://linear.app/autogpt/issue/SECRT-2708). **What:** Both LaunchDarkly SDKs go, along with the `launchdarkly` and `dual` flag backends and the switch between them, on the backend and the frontend. Flags behave exactly as they do today: the backend asks PostHog with the same person properties, and the browser reads `/api/feature-flags` as it already does everywhere. **How:** The backend's LaunchDarkly `Context` was also the user context PostHog's person properties were built from. It is now a small dataclass with the same fields, cache and degraded-lookup rules. The backend sends the same properties: `role`, `email`, `email_domain`, `created_at`, the per-request `country`, `environment` and `is_anonymous`. ### Changes 🏗️ **Backend** - Removed the `launchdarkly-server-sdk` dependency; `poetry.lock` regenerated with Poetry 2.5.1. The `launchdarkly` extra on `sentry-sdk` was already gone (#14805). - `backend/util/feature_flag`: - Every read goes to PostHog. - Removed: LaunchDarkly init/shutdown, `get_client`, the dual shadow reads and mismatch log, and `mock_flag_variation`. - Settings: removed `FeatureFlagBackend`, `feature_flag_backend` and `launch_darkly_sdk_key`. - Infra #452 (merged 23 Oct) already removed `LAUNCH_DARKLY_SDK_KEY` and the flag-backend setting from prod. If any environment still sets either variable, `Settings` allows extra keys, so it is ignored and boot is unaffected. A test covers this. - Deleted `scripts/feature_flag_sync.py` and `scripts/sync_feature_flags_to_posthog.py` with their tests. They only copied LaunchDarkly flags into PostHog. - `country` (#14799) already reaches PostHog as the person property `country`, per read and never stored. - It stays `country`, not `$geoip_country_code`: server-side reads get no GeoIP, and the trial-offer flag in PostHog targets `country`. - An anonymous stand-in context (a failed user lookup) still sends no country, as before. - `.env.default`: removed `LAUNCH_DARKLY_SDK_KEY` and `FEATURE_FLAG_BACKEND`. - LaunchDarkly wording in comments and docstrings now says PostHog or feature flag. - Two messages Otto reads now say "feature flag" instead of "LaunchDarkly flag". - Three OpenAPI descriptions changed; `openapi.json` was regenerated. **Frontend** - Removed `launchdarkly-react-client-sdk` (lockfile regenerated), `LDProvider` and the LaunchDarkly context building, including the device context from #14288. - Removed the `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` switch with its `launchdarkly`, `dual` and client-side `posthog` paths. Every environment runs `server`, so `useFlagSource` now always reads `/api/feature-flags`. - Removed `environment.areFeatureFlagsEnabled` / `getLaunchDarklyClientId`, and the `setPersonPropertiesForFlags` call, which only ran in the client-side `posthog`/`dual` modes. - Retired the `feature_flag_mismatched` event, which only the dual backend sent. - Experiments: `useExperiment.ts` and `useReportAssignment.ts` stay on PostHog (the LaunchDarkly experiment bridge was already deleted). The browser's assignment `source` is now only `"posthog"`. - `.env.default`: removed `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED`, `NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID` and `NEXT_PUBLIC_FEATURE_FLAG_BACKEND`. - Tests for the LaunchDarkly, dual and client-side PostHog paths are removed. Tests for the remaining behaviour now mock the flag seam instead of the LaunchDarkly SDK. **Docs and image** - Updated `docs/engineering/feature-flags.md`, `activation-metrics.md`, `tracking-plan.md`, `docs/platform/contributing/managing-llm-models.md`, `frontend/CONTRIBUTING.md` and the threat model. - Single-container image: removed `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED` and the step that deleted `ldclient`'s bundled test certificates. **Kept on purpose** - The backend `AssignmentSource` still accepts `"launchdarkly"`, so existing `experiment_assignment` rows recorded with it stay valid. - The stored `routingSource` value `"ld"` is unchanged, because it is a stored value. **Config changes** - Removed env vars: `LAUNCH_DARKLY_SDK_KEY`, `FEATURE_FLAG_BACKEND` (backend); `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED`, `NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID`, `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` (frontend). - Leftover values are ignored. Infra #452 already removed the LaunchDarkly key and flag-backend setting from prod. Any remaining `FEATURE_FLAG_BACKEND` or `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` values in infra or Vercel can be dropped whenever convenient. **Rollback:** revert this PR and [infra #452](https://github.com/Significant-Gravitas/AutoGPT_cloud_infrastructure/pull/452), so `LAUNCH_DARKLY_SDK_KEY` and the Vercel LaunchDarkly vars come back. Then set `FEATURE_FLAG_BACKEND=launchdarkly` (backend) and `NEXT_PUBLIC_FEATURE_FLAG_BACKEND=launchdarkly` (frontend, needs a rebuild). LaunchDarkly's flags must still match PostHog's, because LaunchDarkly hasn't served since 16 Oct. Reverting this PR alone keeps PostHog serving, since it is already the code default. ### Agents and large language models used - Claude Code — Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Backend flag unit tests (`backend/util/feature_flag/`, `api/features/feature_flags/routes_test.py`, `data/subscription_trial_config_test.py`, `util/metrics_test.py`) pass locally, including real PostHog clients evaluating the country rule, the employee cohort and the visitor split in-process - [x] Frontend `pnpm lint`, `pnpm types` and `pnpm test:unit` pass (flag, experiment, PostHog provider and affected page tests) - [x] `openapi.json` regenerated; `poetry.lock` and `pnpm-lock.yaml` regenerated - [ ] CI green - [ ] After merge and deploy: flags on dev and prod still match today's (e.g. `/api/feature-flags` for an employee and for a signed-out visitor) #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
1b4bade6ad | fix(frontend): reload once when a chunk fails to load after a deploy (#16312) | ||
|
|
b36cf89b11 |
refactor(frontend): CSS-first theme on Tailwind 4 (#16429)
### Why / What / How **Why:** P12 of the SECRT-3097 design-system cleanup (Tailwind 4 CSS-first theme, the follow-up SECRT-3102/#16156 left for "its own reviewed change"). The theme lived in `tailwind.config.ts`, so `@shadcn/lint`'s `no-unknown-classes` could not see the real class grammar (it missed typos such as `text-bodyy`), and the roles, type scale and plugins were JS that Tailwind 4 reads natively from CSS. **What:** the colour roles, the shadcn aliases, the type scale and the radii move into `@theme` in `globals.css`; `dark:` becomes `@custom-variant dark`; the three plugins and the tailwind-scrollbar port become `@utility`; Prettier sorts classes with Tailwind 4, so `tailwindcss-v3` is gone. **No visible change:** the compiled stylesheet is the same as `dev`'s (evidence below). **How:** `@theme inline reference`: - `inline` writes each value into the class. - `reference` keeps Tailwind from also writing the variables onto `:root`. So a role's theme entry can share the name of the RGB triplet it reads: `--color-surface: rgb(var(--color-surface) / 1)`. `bg-surface` compiles to exactly what it compiled to before, `rgb(var(--color-surface) / 1)`, and `bg-surface/50` to `rgb(var(--color-surface) / 0.5)`, through the same compat rewrite. Every consumer of `rgb(var(--color-*))` (tutorial.css, shadows, CSS modules) keeps working untouched. This PR includes P10 (#16411) until it merged. It has, and `origin/dev` is merged in, so the diff is P12 only. ### Changes 🏗️ **`src/app/globals.css`** - `@theme inline reference` registers: - all 47 roles and the 27 shadcn aliases, each reading its `:root` triplet; - fonts (`--font-*`), sizes with their bundled line-heights (`--text-*`, `--text-*--line-height`), `--leading-*` and `--tracking-*`; - the radii, the 4 shadcn geometry tokens included: `--radius-sm/md/lg` from `:root`'s `--radius`, and the bare `rounded`. - These are both the app's tokens and Tailwind 3's defaults, so nothing gains or loses a class. - `@custom-variant dark (&:is(.dark *))` replaces `darkMode: "class"`. That is the selector `darkMode: "class"` compiled to under Tailwind 4; the app never sets `.dark`, as before. - `@utility smooth-shadow-ring-sm`, `grain-overlay` and `text-shadow-message-table-{violet,indigo,blue}` replace the three JS plugins. Their `addBase` variables go in an `@layer base` block. - The tailwind-scrollbar 3.1 port moves from `tailwind.scrollbar.ts` into `@utility`: - `scrollbar`, `scrollbar-thin` and `scrollbar-none`; - `scrollbar-{track,thumb,corner}-*`, with opacity modifiers and `[color]`; - `-rounded` / `-rounded-*`, and `scrollbar-w-*` / `scrollbar-h-*`. **`tailwind.config.ts`** (−215 lines): colours, `fontSize`, `lineHeight`, `letterSpacing`, `fontFamily`, `borderRadius`, `textShadow`, `darkMode` and four plugins are removed. What's left is the preset, the blocklist, `content`, the container, spacing, shadows, the message-table gradient, keyframes, animations, transitions and `tailwindcss-animate`. **`tailwind.v3-preset.ts`** (trimmed) - These keys are removed, because Tailwind 4 reads them from `--color-*` and the CSS theme: `fontSize`, `fontFamily`, `lineHeight`, `letterSpacing`, `borderRadius`, and the 15 colour-derived keys (`backgroundColor: theme("colors")`, `ringColor`, `fill`, …). - **The raw palette stays in JS.** `@shadcn/lint` reads every colour in a `@theme` of `globals.css` as a design token. With the palette there, `bg-zinc-200` would stop failing `no-raw-colors`. - So `src/components/styles/colors.ts` (the app's palette values) and `customGray` move into the preset as `APP_COLORS`, merged step for step over Tailwind 3's palette, as `theme.extend` merged them. - `colors.ts` and `tailwind.scrollbar.ts` are deleted. **Lint (`scripts/eslint-rules/design-system.mts`, `src/app/tailwind-grammar.css`)** - The grammar file imports `globals.css`, so `no-unknown-classes` knows the theme, variants and `@utility` classes. - `@shadcn/lint`'s `no-raw-colors` adds a second check once a theme declares colour tokens: it reports any colour-like class that isn't a token. Under `scanAllStrings` that misreads `fill-mode-both`, `shadow-subtle`, `text-delta` and NotionAvatar's generated SVG attributes: 1,212 false positives. - The ratchet now drops only that `undeclaredToken*` verdict, so the rule reports exactly what it reported before. `pnpm lint:shadcn` output is identical to `dev`'s, line for line, with the baseline unchanged. - `readColorRoles` now slices `:root` to its own closing brace. `globals.css` has a `.dark {` again, inside `@layer base`. - Messages now name `globals.css`'s `:root` and `@theme`, not `tailwind.config.ts`. **Prettier:** `.prettierrc` uses `tailwindStylesheet: ./src/app/globals.css`. `tailwindcss-v3` (`npm:[email protected]`) is removed from `package.json` and the lockfile (−211 lockfile lines, mostly Tailwind 3's own dependencies). `pnpm format` re-sorts class strings with Tailwind 4's order: 463 files, 1,574 lines, nothing but reordered classes (checked below). **Tests** - `design-system-contract.test.ts` reads the roles from `@theme` instead of the JS config. - New checks: - `color-roles.ts` lists every non-alias role; - `@custom-variant dark` is the one dark selector; - `APP_COLORS` stays frozen, and `globals.css` declares no palette colours. - `tailwind3-compat.test.ts` reads declarations with whitespace collapsed (Prettier wraps long values in `globals.css`) and with the `!important` flag Tailwind sets on an `@utility` declaration. **Docs and comments:** `docs/engineering/design-system.md`, `CONTRIBUTING.md`, and the comments in the preset, compat plugin, `tw-merge.ts`, Text helpers, codemod and `color-roles.ts` now describe the CSS-first theme. | | lines (adds + deletes) | |---|--:| | theme, config, preset, lint, tests, docs | 1,259 | | class re-sort by Prettier (463 files) | 1,574 | | lockfile | 211 | | **total** (482 files) | **3,044** | ### Evidence: zero visual change **1. The compiled stylesheet** is the same as `dev`'s. Both checkouts compile `globals.css` and the builder's `tutorial.css` through `postcss.config.mjs`, and every declaration is dumped as `selector path :: property: value`, whitespace collapsed (5,303 declarations). - `tutorial.css`: byte-identical. - `globals.css`: the only differences, all inert: - Tailwind's base layer (`* { scrollbar-color/width: initial }`, `--smooth-ring-*`) sits near the top of the file instead of the end. It stays inside `@layer base`, and the layer order (`properties`, then `base`) is unchanged. Unlayered utilities beat it either way. - `dev` wrote each `text-shadow-message-table-*` declaration twice: Tailwind 4's native `text-shadow-*` from the config key, then the plugin's. The plugin's came second and won. Only that winning declaration remains, plus the `--tw-text-shadow-*` `@property` registrations only the dead one used. - A per-property check of rule order (which rule wins when two set the same property): identical, apart from that base-layer rule. **2. A synthetic class list** of 21,398 classes, compiled on both sides: - every palette colour and role × 21 colour utilities, plain, `/50` and `hover:`; - every text size (plain, `md:`, `/6`, `/[1.1]`), every leading and tracking step, every radius × 5 corners; - the fonts, `dark:` classes, and every plugin and scrollbar utility. Same CSS apart from the two items above. Unused scrollbar-colour classes with an opacity modifier carry one extra overridden declaration and sort differently among themselves. No class in `src` uses one. **3. `scripts/check-token-equivalence.ts --base <dev checkout>`** - 1,785 opacity swatches: 0 differing pixels. - 572 text class lists × 12 cases: 0 failing. 6 `cn()` checks: 0 failing. - 120 of 122 pairs identical. The other 2 rows (`shadow-[0_0_0_1px_hsl(var(--sidebar-*))]`) fail the same way when `dev` is checked against itself: they compare the HSL variables P2 deleted. The full table matches `dev`'s self-check row for row. **4. Class re-sort:** each of the 785 string/template literals Prettier changed has the same class tokens and the same `twMerge()` result. Literals were paired in order with the TypeScript AST, base against head, in all 463 files. **5. Screenshots** at 1440 and 390: _being added in a follow-up edit of this description_. Visible changes: **none**. ### Agents and large language models used - Claude Code with Claude Opus 5.5 (1M context) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan: - [x] compiled CSS of `dev` vs this branch (declaration dump and per-property rule order) - [x] synthetic 21,398-class compile on both sides - [x] `check-token-equivalence.ts --base` against `dev` - [x] re-sorted literals: same tokens, same `twMerge()` result - [x] `pnpm lint:shadcn` output identical to `dev`'s - [x] `pnpm lint`, `pnpm types`, contract, compat and tw-merge tests - [ ] full `pnpm test:unit` (CI) - [ ] before/after screenshots 1440 and 390 #### For frontend changes (design system, see `docs/engineering/design-system.md`): - [x] `pnpm lint` passes, including the `design-system/*` rules; I did not raise any count in `scripts/eslint-rules/design-system-baseline.json` - [x] No `dark:`/`light:` classes, raw palette classes or hex/`rgb()` colours added - [x] No imports from `src/components/__legacy__`, `legacy/` paths or `@/components/ui` - [x] Copy and primitives untouched - [ ] Before/after screenshots at 1440 and 390 px (being added); no pixel change expected and checked by the compiled-CSS comparison above - [x] No user-facing copy changed #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] Configuration changes: `.prettierrc` (`tailwindStylesheet`), `package.json` (`tailwindcss-v3` removed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- conductor-workspace-link --> --- [Open workspace in Conductor](https://app.conductor.build/pro-user-8d964eae-256d-46a9-b469-fd3c36fa6673/w/202) --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
0257b4be7f |
refactor(frontend): delete the legacy layer; design-system lint is an error everywhere (SECRT-3153, DS4b, DS3a tail) (#16411)
### Why / What / How
**Why:** P10 of the SECRT-3097 cleanup (SECRT-3153, DS4b, DS3a tail).
Guardrails (#16394) and every directory PR (P2–P9) are merged, so the
legacy layer has no importers left and the lint can fail on everything
it checks.
**What:** delete `src/components/__legacy__/` and `LegacyButton`, move
the last 26 `legacy-*` buttons to the Button atom's `compact-*` family,
make `design-system/baseline-outdated` an error (so every
`design-system/*` rule now fails lint), shrink the baseline, and have
the colours story read the role tokens.
**How:** `dev` grandfathers violations through a per-file ratchet
(`design-system-baseline.json`) rather than the plan's
`.oxlintrc.strict.json` + per-directory `warn` overrides. On `dev` every
`design-system/*` rule was already `error` except `baseline-outdated`
(`warn`). So "error everywhere" here means:
- `baseline-outdated` → `error`, so a file that drops below its count
must commit the lower count.
- The Button override that switched off
`no-legacy-import`/`no-legacy-button-variant` is gone.
- Every rule this PR clears has no file left in the baseline.
The `off` overrides for tests, stories, generated code and the atoms'
own `no-restyle` stay; they are scope, not debt.
### Changes 🏗️
**Deleted**
- `src/components/__legacy__/` (`Button.tsx`, `Sidebar.tsx`,
`Status.tsx`; 0 importers).
- `atoms/Button/LegacyButton.tsx`, and `legacyButtonVariants`,
`LegacyButtonProps` and `isLegacyButton` from `helpers.ts` and
`Button.tsx`.
- The `LegacyVariants` Button story. It is replaced by
`CompactVariants`.
- The raw-palette `AllVariants` colours story.
**LegacyButton call sites → `compact-*`**
- 26 sites: admin marketplace approve/reject, admin search, admin Add
Dollars, the MCP tool dialog, `app/error.tsx`, the cron scheduler and
its dialog.
- `legacy-default/outline/destructive` →
`compact-primary/outline/destructive`, `size="compact"`.
- The compact family keeps the same `rounded-md` shape, 36 px height, 16
px padding and caption/body text. It does not become pills, so the 5.7
item 2 pill change is not in this PR.
- The 15 `no-legacy-button-variant` suppressions are removed.
**Lint**
- `.oxlintrc.json`: `design-system/baseline-outdated` `warn` → `error`;
the `atoms/Button/**` override is deleted.
- `design-system-baseline.json` is regenerated: 405 → 392 files.
- Down to zero: `no-dark-variant` 46 → 0, `require-suppression-reason` 3
→ 0, `no-arbitrary-values` 2 → 0.
- `no-raw-colors` 93 → 1, `no-unknown-classes` 15 → 7.
- `no-legacy-import` and `no-legacy-button-variant` stay at 0.
- Three reason-less `eslint-disable` comments get a `-- reason`. The
UUID regex in `share/routes.ts` gets a reasoned suppression; it is a
string, not classes.
- `docs/engineering/design-system.md`, `frontend/AGENTS.md` and the
plugin comment now say that `baseline-outdated` fails, and that the
legacy layer is deleted.
**Tokens**
- New `src/components/styles/color-roles.ts` holds the role list,
grouped by job. `tailwind.config.ts` registers roles from it; the list
was inline there before.
- `tokens/colors.stories.tsx` now draws its groups from that file. It
reads each value and each shadcn alias's target from the loaded `:root`
CSS, so it can't drift. It no longer shows `styles/colors.ts`.
- Contract test: the two primitives on the known-list (`RelativeTime`,
`Dialog`) render no DOM element of their own (a fragment; the Radix/vaul
root). The list is renamed `ELEMENTLESS_PRIMITIVES` and its comment says
so, instead of treating them as debt.
**Small zero-pixel fixes**
- `scrollbar-thumb-zinc-300` → `scrollbar-thumb-border-strong`: same
#C5C5C9.
- `StoryCode` is on roles (Storybook only).
- Classes that generate no CSS are removed from two stories: `text-md`,
`mb-19`.
- The error pages use `bg-gray-50` #F9FAFB → `bg-surface-muted` #F9F9FA.
### Pre-flight (`origin/dev` at
|
||
|
|
c0de8f1148 |
feat(platform): one Microsoft connection for mail, calendar, files, Teams, Copilot and Work IQ (#16268)
### Why / What / How **Why:** AutoGPT's only Microsoft connection was Microsoft 365 Copilot's sign-in code, which reads work content for Copilot chats and nothing else. Otto's mail, calendar, files and Teams tools, which come next, need the same account with more permissions, and Work IQ needs an admin to approve it. A connection per product would mean signing in to the same account several times, with nothing saying which service an account or its organization can actually use. **What:** one `microsoft` connection per Microsoft account, for mail, calendar, files, Teams chats, Microsoft 365 Copilot and Work IQ. The panel shows each service's status and asks only for what is missing. - **Runs on it now:** Copilot chats. There is no separate Copilot sign-in any more, and Copilot connections made before this keep working. - **Granted and status-checked, used next:** mail, calendar, files and Teams, which Otto's tools for them will use, and Work IQ. Nothing uses them until those PRs land. - **What each permission is for** is said wherever someone grants it: in the panel, on the Copilot card and on the admin setup page (`/microsoft-setup`). | Service | Permissions | What it lets AutoGPT do | Used by | |-----------------------|-------------------------------|----------------------------------------------------------------------------------|------------------------------| | Mail | `Mail.ReadWrite`, `Mail.Send` | Otto reads, drafts and sends the person's email | Otto's mail tools (next) | | Calendar | `Calendars.ReadWrite` | Otto sees, adds and changes their calendar events | Otto's calendar tools (next) | | Files | `Files.ReadWrite` | Otto opens, creates and edits their OneDrive files | Otto's file tools (next) | | Teams chats | `Chat.ReadWrite` | Otto reads and sends messages in their Teams chats | Otto's Teams tools (next) | | Microsoft 365 Copilot | Copilot's read scopes (below) | Microsoft 365 Copilot answers chats routed to it, from what the account can open | Copilot chats (this PR) | | Work IQ | `WorkIQAgent.Ask` | Otto asks Work IQ, which can read and change the person's Microsoft 365 content | Otto's Work IQ tool (next) | Copilot's read scopes: `Sites.Read.All`, `Mail.Read`, `People.Read.All`, `OnlineMeetingTranscript.Read.All`, `Chat.Read`, `ChannelMessage.Read.All` and `ExternalItem.Read.All`. Work IQ's permission is admin-consent only. The default sign-in asks for `User.Read` plus mail, calendar and files. The Copilot card adds Copilot's scopes, Teams chats has its own **Allow**, and Work IQ is never asked for together with anything else. **How:** - **Sign-in** (`integrations/oauth/microsoft.py`): browser sign-in (authorization code with PKCE) when the deployment turns it on (`MICROSOFT_BROWSER_SIGN_IN=true`, with an app secret), or a sign-in code otherwise, so a self-hosted install without its own Entra app still connects through AutoGPT's public app. Both produce the same credential and record which app issued it. Adding permissions names the connection it's for in both modes (`device-auth/initiate` now takes `credential_id`, as `/login` does). A secret alone doesn't turn browser sign-in on: the Teams bot's app has one long before its Entra app has the browser redirect. - **Tokens** (`microsoft_tokens.py`): every token request goes out as the app that issued the grant, and only the deployment's own app sends its secret. Work IQ tokens are minted from the same refresh token: a Microsoft refresh token [isn't tied to one resource](https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens), so once an admin approves Work IQ, every existing connection gets it without signing in again. A refresh keeps the account and any title the person gave the connection. - **Identity** (`microsoft_identity.py`): accounts are matched on tenant and object ID, never the email address or the username, which Microsoft can change. A personal and a work account with the same address stay separate connections, a renamed account keeps its connection, and adding permissions to one account refuses any other account, through the browser or with a code, however much it was granted. - **Services and status** (`microsoft_services.py`, `microsoft_access.py`, `POST /api/integrations/microsoft/check`): each service reports ready, needs your permission, needs your admin, needs organization setup (Work IQ), work accounts only, or not available (for example, no Exchange mailbox). People grant one service at a time or all at once, and an admin gets one organization-wide approval link, offered only when a Graph service is pending and only for connections this deployment's app issued. Every probe, Work IQ's included, is bounded at 20 seconds. Work IQ shows "needs setup" only when Work IQ itself refuses; throttling, server and network errors show it unavailable. A check Microsoft refuses comes back as a 400 with its reason, which the panel shows; a grant Microsoft no longer accepts asks the person to sign in again. - **Work IQ is always its own approval**: never part of "Allow everything listed", its sign-in-code counterpart or the organization approval. Until Microsoft has set Work IQ up for an organization, asking for it fails everything asked with it (`AADSTS650052`), and its status can't show that beforehand. Its permission is [admin-consent only](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/work-iq/permissions), so a missing consent shows "needs your admin". An admin approves it from its own Allow (or its "Connect Work IQ" code without browser sign-in), which can approve it for everyone, and the "Turn on Work IQ (admins)" link sets it up. - **Errors** (`microsoft_recovery.py`, `lib/microsoft-setup.ts`): Microsoft's `AADSTS` codes become a reason and a next step (ask your admin, enable Work IQ, try again) instead of a generic failure. The organization approval tab reports a refused approval (Microsoft marks those `admin_consent=True` too) instead of saying it was approved. - **Copilot** (`copilot/transports.py`, `executor/processor.py`, `microsoft_365_copilot/service.py`): the Copilot chat route accepts any Microsoft connection that has Copilot's permissions. - **Frontend**: `components/contextual/MicrosoftConnection/` is the panel that Settings → Connect Service and the Microsoft 365 Copilot card share. Its account picker is the design-system `Select`, and starting again or checking again clears the previous attempt's error. Without browser sign-in it can still connect another account with a code, and sign-in code errors show the same recovery messages as browser sign-in. Microsoft 365 Copilot's Disconnect waits until it knows which connection it removes. - **External API**: its OAuth flow refuses Microsoft and points to Settings. Microsoft only redirects to AutoGPT's own callback, and that flow would store the connection without matching it to an existing Microsoft account. ### Changes 🏗️ - Backend: the `microsoft` provider (`ProviderName.MICROSOFT`, its OAuth and sign-in-code handlers), `microsoft_identity` on credential metadata, `POST /api/integrations/microsoft/check`, re-authorization refused across different Microsoft accounts, `device-auth/initiate` accepting `credential_id`, the Copilot route reading Microsoft connections, the `MICROSOFT_BROWSER_SIGN_IN` setting (off by default), and the external API refusing Microsoft OAuth. - Frontend: the Microsoft connection panel and its services list with what each service's permissions are for, organization approval and Work IQ setup links, the admin setup page (`/microsoft-setup`), the OAuth callback explaining Microsoft errors and the organization approval page, the Copilot card and onboarding's connect step using the panel, and Connect Service keeping the Microsoft card's description. - `openapi.json` regenerated. - `docs/engineering/microsoft-local-e2e.md`: what each permission is for, Entra app settings per environment, environment variables, and local testing. **Deploying:** before this release reaches an environment, that environment's Entra app needs access token version 2 and then personal accounts in its sign-in audience (step 1 in the doc). Sign-in codes now go to `/common`, where the Copilot-only connection used `/organizations`, and without step 1 personal accounts can't sign in. Browser sign-in stays off until `MICROSOFT_BROWSER_SIGN_IN=true`, which also needs the `{frontend}/auth/integrations/oauth_callback` redirect. Dev's app needs step 4 (the connection's permissions listed under API permissions, so an admin's approval outside AutoGPT covers every service) and the flag. Prod's needs step 1 before this release reaches it, and step 4; then the redirect and the flag. The Microsoft card in Connect Service and the Copilot card's new panel ship either way. ### Agents and large language models used - Claude Code with Claude Opus 5.5: design, implementation, tests and live testing. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Backend: `pytest backend/api/features/integrations backend/integrations/oauth backend/integrations/microsoft_access_test.py`: 351 passed on the final head. That includes browser sign-in staying off until the setting is on, sign-in codes bound to their connection, another account refused even with a narrower grant, a renamed account keeping its connection, and a stalled Work IQ probe timing out. Pyright on the changed files: 0 errors. Black and isort clean. - [x] A local merge of `369b1aa` with current `dev`: 296 backend tests (the above plus `copilot/transports_test.py`, `integrations/microsoft_365_copilot` and `util/architecture_test.py`), `pnpm types`, the exported `openapi.json`, and the whole `vitest` suite in four shards (12,300+ tests). All pass. Two of dev's own tests (`RateLimitResetDialog`, `UsageLimitReachedCard`) fail only outside UTC, on plain dev too. - [x] Frontend on the final head: `vitest` over `MicrosoftConnection`, `IntegrationsPanel`, `DeviceAuth`, the OAuth callback and `src/lib`. `pnpm types`, `pnpm lint` and `pnpm format` pass. - [x] After Pwuts's review, with current `dev` merged in (one conflict in `ManageConnectionDialog`, resolved onto dev's design tokens, and the panels moved off the raw colors dev's new lint refuses): backend 427 passed (`api/features/integrations`, `integrations/oauth`, `microsoft_access_test.py`, `copilot/transports_test.py`, `integrations/microsoft_365_copilot`); frontend 2,384 passed over `src/components/contextual`, `src/lib`, the auth routes and the no-navbar pages, including a new test for `/microsoft-setup`. `pnpm types` and `pnpm lint` pass, and `openapi.json` matches the export. - [x] Live, on a local stack with the dev Entra app and a work account: - [x] Browser sign-in through Microsoft's consent screen creates the connection. - [x] Statuses: files, Teams chats and Copilot ready; mail and calendar "not available" (the tenant has no Exchange mailbox); Work IQ "needs your permission" (now "needs your admin", since its permission is admin-consent only). - [x] "Allow everything listed" reaches Microsoft, which refuses Work IQ for a tenant that hasn't set it up (`AADSTS650052`); the panel says the organization needs to enable Work IQ and offers the admin setup link. - [x] The Microsoft 365 Copilot card shows the connection as connected and used for new chats, and a real Copilot chat ran on it end to end. - [x] The organization approval link opens the tenant's admin consent page with the right app, scopes and redirect. Live, Microsoft then refused the whole approval with `AADSTS650052`, because the link asked for Work IQ in a tenant that hasn't set it up (its token request answers "needs your permission" there). Fixed: the approval never asks for Work IQ, which has its own Allow. Re-run live after the fix: the link (13 Graph scopes, no Work IQ) reaches Microsoft's "Permissions requested — Review for your organization" page for the app; stopped there without accepting. - [x] Sign-in code against AutoGPT's public app, with the deployment's secret removed: Settings offered only "Connect Microsoft", and a code entered at microsoft.com/devicelogin connected the work account with each service's status. Asking for Work IQ with a code then stopped on Microsoft's own `AADSTS650052` page, as expected for this tenant. **For reviewers:** the review bot's sandbox has no Microsoft account, so it can't finish a sign-in and reports "Review incomplete" (twice, with no blockers). The live runs above are where sign-in was exercised. #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**): a new `MICROSOFT_BROWSER_SIGN_IN` (default `false`, added to `backend/.env.default`) alongside the existing `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` and `MICROSOFT_TENANT_ID`; the Entra app changes are under **Deploying**. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]> |
||
|
|
35879eafa8 |
feat(frontend): design-system guardrails as lint rules with a per-file baseline (SECRT-3097 wave 0) (#16394)
### Why / What / How **Why:** Wave 0 of SECRT-3097. Before the design-system migration starts, new code has to stop adding the patterns the migration removes. Making the rules errors everywhere would fail on about 9,000 existing violations, so every rule is a **ratchet**: existing violations are grandfathered per file, and new ones fail `pnpm lint`. **What:** Design-system guardrails as normal lint rules inside the **existing `pnpm lint` step**, plus the rules doc and process changes (P0 + P1 of the audit, without the visual-diff job). - **No new CI job or workflow step:** `platform-frontend-ci.yml` is unchanged. - **No pixel changes:** no component code changes; I compiled the app stylesheet before and after and the CSS is byte-identical. - Supersedes #16022 and #16025 (both closed). **How:** - **`scripts/eslint-rules/design-system.mts`** is an oxlint JS plugin (`design-system/*`) that `.oxlintrc.json` loads, so `pnpm lint` (→ `lint:shadcn` → `oxlint`) runs it. It wraps the six `@shadcn/lint` rules and adds five of its own. - **Grandfathering:** each rule collects its reports per file, leaves out the ones an `eslint-`/`oxlint-disable` comment suppresses, and compares the count with `scripts/eslint-rules/design-system-baseline.json` (per file, per rule). - At or under the count: passes. - Over the count: fails, listing every violation of that rule in the file. - Not in the baseline (every new file): any violation fails. - Under the count: a `design-system/baseline-outdated` **warning** (lint still passes) telling you to lower it. - **Messages point to the token:** for raw palette classes and arbitrary colours, the wrapper rewrites shadcn's message. It uses the exact class map in `scripts/codemod-tokens.ts` and the role values in `globals.css`: - `"text-zinc-500" uses the raw Tailwind palette. Use "text-foreground-tertiary" instead: the design token with the same colour.` - `"bg-[#EFEFF0]" hardcodes a colour. Use "bg-surface-hover" instead…` - `"font-[600]" hardcodes a value. Use "font-semibold" instead…` - With no exact match, the message lists the roles for that utility (`text-` roles, `bg-` roles, …) and says to check the screen. #### What each rule blocks All are `error` in `src/**` and grandfathered per file. They're off in tests, `src/tests`, `src/playwright`, `src/mocks` and the generated API client. Stories skip the four styling rules (as in #16022). | Rule | Blocks | Grandfathered | | --- | --- | --: | | `design-system/no-raw-colors` (shadcn) | Raw palette classes (`bg-zinc-200`, `hover:text-neutral-500`) in `className`, `cn`/`cva` and any string (`scanAllStrings`), with the same-colour token in the message | 3047 | | `design-system/no-arbitrary-values` (shadcn) | Arbitrary values except layout and transitions; colours (`bg-[#fff]`, `rgb()`) get the same-colour token, sizes the same-value token | 627 | | `design-system/no-restyle` (shadcn) | Restyling a primitive through `className` outside atoms/molecules/organisms; `Text`/`Icon`/`Skeleton` contracts from #16022 | 4275 | | `design-system/no-inline-styles` (shadcn) | `style={{}}` / `<style>` for static values | 218 | | `design-system/require-static-classes` (shadcn) | Class strings the linter can't read (`` `bg-${tone}` ``) | 106 | | `design-system/no-unknown-classes` (shadcn) | Classes Tailwind doesn't generate (`text-md`, `flex-end`, `border-1.5`), with an allow list for PostHog/xyflow markers and the `agpt-*` classes in `globals.css` | 86 | | `design-system/no-dark-variant` | `dark:` and `light:` classes in any string (they never render) | 382 | | `design-system/no-legacy-import` | Imports from `__legacy__`, `legacy/`, `Legacy*` modules (`LegacyButton`), `@/components/ui`, and Button's internal variant helpers outside `atoms/Button` | 8 | | `design-system/no-legacy-button-variant` | `"legacy-default"`, `"legacy-outline"`, `"legacy-sm"`, …, with the design-system variant to use | 80 | | `design-system/hugeicons-only` | `lucide-react`, `@phosphor-icons/react`, `react-icons`, `@radix-ui/react-icons`, and `HugeiconsIcon` outside `atoms/Icon` | 47 | | `design-system/require-suppression-reason` | `eslint-disable`/`oxlint-disable` comments without `-- reason` | 33 | | `design-system/baseline-outdated` (warn) | Nothing; it warns when a file's count can be lowered | — | `src/components/__tests__/design-system-contract.test.ts` (in `test:unit`) fails when: - a `globals.css` colour role isn't registered in `tailwind.config.ts`, or the other way round; - `.dark` redefines a name `:root` lacks; - a colour is added to `styles/colors.ts`; - `components.json` points at a missing path; - an atom or molecule lacks `data-slot` (5 existing ones are on a known list that only shrinks). #### How to run it locally ```bash cd autogpt_platform/frontend pnpm lint # everything CI runs; design-system rules included pnpm lint:shadcn # only oxlint (the design-system and compiler rules), ~9 s ``` #### How to update the baseline ```bash pnpm lint:ds-baseline # rewrites scripts/eslint-rules/design-system-baseline.json ``` - **After removing violations:** lint shows a `baseline-outdated` warning. Run it and commit the lower counts so they can't come back. - **After moving, renaming or splitting a file:** counts are keyed by path, so the moved file fails as new. Run it and say in the PR that the increase is a move. - **Never** raise a count to make new code pass. Fix the code, or suppress one line with a reason. The baseline is in CODEOWNERS. #### `no-unknown-classes` grammar (G6) `@shadcn/lint` loads `tailwind.config.ts` with plain Node, which couldn't resolve its extensionless relative imports, so the rule never saw our theme and flagged our own tokens (`leading-inherit`, `tracking-heading`). Three changes fix that: - `components.json`: `aliases.ui = "@/components/atoms"`; `tailwind.css` stays `src/app/globals.css` (the app stylesheet, where the shadcn CLI writes and where the lint reads colour tokens). `globals.css` has no `@import "tailwindcss"`, so `@shadcn/lint` reads the grammar from the new `src/app/tailwind-grammar.css` (`@import "tailwindcss"; @config "../../tailwind.config.ts";`), the one stylesheet that imports Tailwind. Nothing imports that file, and the plugin drops the notice `@shadcn/lint` prints about the fallback. - `tailwind.config.ts` imports its three siblings with `.ts` extensions (`tsconfig.json`: `allowImportingTsExtensions`). - `lint:shadcn` sets `NODE_OPTIONS=--disable-warning=MODULE_TYPELESS_PACKAGE_JSON` to silence Node's warning about loading a `.ts` config. **Zero-pixel check:** `globals.css` compiled through `postcss.config.mjs` before and after these changes gives byte-identical output (354,799 bytes). ### Changes 🏗️ - **Lint (P0):** - `scripts/eslint-rules/design-system.mts` (new plugin); - `design-system-baseline.json` (generated, 1,070 files); - `update-design-system-baseline.mts` (`pnpm lint:ds-baseline`); - `.oxlintrc.json` (loads the plugin; rules and per-directory overrides); - `package.json` (`@shadcn/lint` 0.1.1 → 0.1.5, `lint:shadcn` env, `lint:ds-baseline`) and `pnpm-lock.yaml`; - `components.json` (`aliases.ui`), `src/app/tailwind-grammar.css`, `tailwind.config.ts` (import extensions only), `tsconfig.json`. - **Tests:** - `scripts/eslint-rules/design-system.test.ts`: `RuleTester` cases for every rule and message, plus an end-to-end run of the oxlint CLI on a fixture project with its own baseline. It covers at baseline → pass, new file → fail, one rule over → fails only that rule, under → warning and exit 0. - `src/components/__tests__/design-system-contract.test.ts`. - **Docs and process (P1):** - `docs/engineering/design-system.md` (new rules doc; every lint message links to it); - `frontend/AGENTS.md` (Taste block; `pnpm lint` note in the pre-completion checks); - `frontend/CONTRIBUTING.md` (Chromatic references, `_legacy__` typos, lint/baseline commands); - `.github/PULL_REQUEST_TEMPLATE.md` ("For frontend changes (design system)" checklist, including manual before/after screenshots at 1440 and 390, which is G8a); - `.github/CODEOWNERS` (design-system paths, the lint configs, `scripts/eslint-rules/` and the doc → `@Significant-Gravitas/frontend-maintainers @Significant-Gravitas/design`); - `.claude/skills/pr-review/SKILL.md` (design-system pointer). - **Removed** from the earlier draft: the standalone `scripts/ds-guard` script and its `design-system-guard` CI job. **Not included, by decision:** - the visual-diff CI job (G8b); - a separate CI ratchet step (G1/G12 as a workflow step); the baseline in lint replaces it; - failing on unused disable directives, which would mean deleting about 80 stale comments in `src`. ### Agents and large language models used - Claude Code with Claude Opus 5.5 (1M context) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `pnpm lint` passes on this branch (oxlint: 0 errors, the 6 `autogpt/no-hookless-use-function` warnings already on `dev`) - [x] `pnpm types` passes - [x] `vitest run scripts src/components/__tests__`: 218 passed (66 in `scripts/eslint-rules`) - [x] By hand: adding `bg-zinc-100` to a grandfathered file fails `no-raw-colors` only, listing all its occurrences with the right tokens; a new file with a `__legacy__` import, `text-neutral-500`, `dark:bg-black` and `bg-[#83838C]` fails all four rules; replacing one `text-zinc-500` with `text-foreground-tertiary` passes with a `baseline-outdated` warning - [x] Compiled app CSS is byte-identical before and after the `tailwind.config.ts` change #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- conductor-workspace-link --> --- [Open workspace in Conductor](https://app.conductor.build/pro-user-8d964eae-256d-46a9-b469-fd3c36fa6673/w/191) --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
09a24074ec |
dx(backend): add a helper that syncs selected env keys between machines (#16266)
### Why / What / How **Why:** the Microsoft app's three settings (`MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`) are shared by every developer machine that runs the Teams bot or the Microsoft connection. Copying them between machines by hand means pasting a secret through a terminal or chat. **What:** a small developer script, `python -m scripts.env_sync`, that compares chosen keys in env files on other machines (over SSH) with a local env file, and copies missing values only when asked. It never prints a value. **How:** - Reads each `--source` (`host:/path/.env`, or `local:/path`) as UTF-8, keeping only the allowlisted keys. Without `--key`, it uses the three Microsoft settings. Hosts named with `--windows-host` (default: `desktop`) are read through PowerShell, with the path quoted for its tokenizer, curly quotes included. - A dry run by default, reporting each key as `matches`, `different`, `missing at target` or `missing at source`. `--apply` writes missing values. Replacing a different non-empty value also needs `--replace`. Sources that disagree are rejected. An empty or missing source value never clears the target. - Which keys change, and whether that replaces a value, is decided from the target as read under its `.sync-lock`. A value another run wrote after the report therefore still needs `--replace`. - Writes go to a local file only, through an atomic replace. The previous file is first copied to `~/.local/state/autogpt-env-sync/` (or `$XDG_STATE_HOME`), a 0700 directory outside any repository, so `git add` cannot stage the copy. The edit is drafted there too. - A sync killed mid-write can leave only the lock and the `.env-sync-*` replacement beside the target, and `.gitignore` covers both. A leftover lock stops later runs with a message naming it. - Tracked files, symlinks and template env files (`.env.default`, `.example`, `.sample` and the like) are refused, by the dry run too. Suffixes match in any case. The tracked check ignores inherited `GIT_DIR`-style and pathspec variables, matches the name literally, and stops on any git answer it doesn't recognise. - `docs/engineering/env-sync.md` explains it. ### Changes 🏗️ - `backend/scripts/env_sync.py`, with tests in `env_sync_test.py` (CLI and reading) and `env_sync_apply_test.py` (writing a target). - `docs/engineering/env-sync.md`. - `.gitignore`: `.env-sync-*` and `*.sync-lock`. ### Agents and large language models used - Claude Code with Claude Opus 5.5: implementation, review fixes and tests. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `pytest scripts/env_sync_test.py scripts/env_sync_apply_test.py`: 39 passed. They cover value selection and conflicting sources, the dry-run statuses and refusals, and Windows hosts with PowerShell quoting. They also cover non-ASCII values under an ASCII locale, `--replace`, and backups kept out of the repository and private. The race with another run, a sync killed at either file replacement leaving nothing `git status` lists, the stale-lock message, a failing read printing nothing of its output, and the refusal of symlinked, template and tracked targets (also in any case, under an inherited `GIT_DIR` or pathspec setting, with an option-like or pathspec-like name, or when git fails) are covered too. - [x] Ran the generated PowerShell read command against a Windows host with a path containing all five quote characters PowerShell ends a string at. - [x] Used it to compare the Microsoft settings across three machines without printing them. - [x] Black, isort, pyright and the secret scanners pass. #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**): `.gitignore` gains two patterns for files a killed sync can leave. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]> |
||
|
|
9b8911e34b |
feat(frontend/onboarding): role-based prompts, guidance, and a way back from a blocked mic on the brain dump step (#16252)
### Why / What / How **Why.** The brain dump feeds the expert recommendations and the first plan, and most people give it nothing. Since Oct 16 (PostHog, as of Oct 26), of 561 people who reached it, 32% completed it and 61% skipped; 440 pressed Write, 121 started a recording and 53 had the microphone blocked ([saved insight](https://eu.posthog.com/project/117338/insights/75kg8Gg3)). The step gave one line of guidance and nothing on what a good answer looks like or why it matters. And a denied (or merely dismissed) microphone prompt silently switched the step to typing with no message and no way back, so those 53 people never got to talk. **What.** Same backend output, same recorder, upload queue, recovery, failure and insufficient states. The step now: - says why sharing helps ("Otto uses it to pick your experts and plan your first week.") and gives a typing lead; - shows role-based "Otto might ask" prompts: one live prompt with "Another question" (above the composer, under the orb while recording, under the guidance at rest where the ambient cards don't fit), and six soft, non-interactive starter cards around the column on wide screens; - explains a blocked or closed microphone prompt above the composer and offers a way back to Talk ("try the microphone again", and "record instead", which now stays), clearing on its own when the user allows the site. **How.** - `prompts.ts` holds one set per `RoleStep` id (8 questions, 2 examples, copy verbatim from the ticket), with `promptSetForRole` falling back to Other; the free text beside "Other" is never read. - `usePromptRotation` lives in `useBrainDumpStep`, so one index carries across rest, typing and recording (as in the prototype) and the completion/skip events can read `prompts_seen`. It starts at 4, auto-advances per screen (rest 12 s only where the live prompt shows, typing 20 s, recording 15 s). It pauses while the ideas are paused, while the tab is hidden, on processing/failed/recovery/insufficient and under reduced motion, and restarts its timer on every advance. - `AmbientPrompts` is an `aria-hidden` layer behind the column (`z-0` under `z-10`), with no border, shadow or focus; a click on a card pauses or resumes every changing idea (the keyboard route is the focus-only "Pause changing ideas" toggle, with `aria-pressed`). Drift 7 px up and 5 px down over 7.2 to 9.6 s; one card swaps every 3.5 s, 7 s under reduced motion. Dimmed while typing/recording, the two cards beside the composer hidden while typing, held still while the dump is being sent, gone on failed/recovery/insufficient. - Blocked microphone: `useMicrophonePermission` wraps `navigator.permissions.query` (unknown where unsupported, listens for `change`); `useMicrophoneRetry` owns the retry and the blocked state. `recorder.start()` takes an optional `onMicAnswer` callback, called as soon as `getUserMedia` settles, so `brain_dump_permission_retried` is sent before `brain_dump_started`, and a `shouldRecord` check, asked once the mic opens, so a prompt answered after the typed text was sent starts no take. The blocked state stays up through a retry, so a second refusal updates the one notice in place instead of remounting it. Resolves SECRT-3198 Resolves SECRT-3199 ### Changes 🏗️ All in `src/app/(no-navbar)/onboarding/steps/BrainDumpStep/` unless noted. **New** - `prompts.ts`: `BRAIN_DUMP_PROMPT_SETS`, `promptSetForRole`, `promptSetNameForRole`. - `usePromptRotation.ts`: the live prompt index, auto-advance and `brain_dump_prompt_advanced`. - `components/OttoMightAsk.tsx`: label, question (`SwapFade`), "Another question", and the one `aria-live="polite"` region. It reads "Otto might ask: …" only after a click on "Another question", never for a timed change. - `components/AmbientPrompts.tsx` + `components/useAmbientPrompts.ts`: the six starter cards. - `components/MicBlockedNotice.tsx`: the `role="alert"` notice (blocked and closed-prompt copy, Hugeicons `MicOff01Icon` in the warning tone). - `useMicrophonePermission.ts`, `useMicrophoneRetry.ts`: permission state, change listener, retry flow. - `useIsWideLayout.ts` (`min-width: 1280px`) and `useIsShortScreen.ts` (`max-height: 800px`), same `useSyncExternalStore` idiom as `useIsTouchDevice`. - Extracted from `BrainDumpStep.tsx` to keep it under ~200 lines: `components/StepHeader.tsx` (headline, guidance, typing lead), `components/ModeSwitchLink.tsx`, `components/SkipButton.tsx`, `components/TypingComposer.tsx`; `toOrbScreen` moved to `helpers.ts`. The markup they render is unchanged apart from what this PR adds. **Changed** - `useBrainDumpStep.ts`: reads the role, wires the rotation and the retry, adds the tracking properties, `typed_fallback {permission_denied}` once per visit, `handleRecordInstead`. - `useBrainDumpRecorder.ts`: optional `promptSet` (sent with `brain_dump_started`), `start({ onMicAnswer })`, `clearPermissionDenied()`. Recording, chunking and upload logic untouched. - `BrainDumpStep.tsx`: the root is now a `relative w-full max-w-screen-2xl` wrapper for the ambient layer; Skip stays outside it, so it keeps its page-corner position. - Copy (`helpers.ts`, `StepHeader.tsx`, `RecordingStatus.tsx`, `BrainDumpStep.tsx`): rest guidance, typing lead, `DURATION_GUIDANCE_COPY`, "You're doing great. Keep going", `OFFLINE_COPY` and the 30-minute caption lose their em dashes. - `TypedFallback.tsx`: optional `rows` (default 6). - `src/services/analytics/posthog-events.ts`: `BRAIN_DUMP_PROMPT_ADVANCED`, `BRAIN_DUMP_PERMISSION_RETRIED` (+ the event-name registry test); `docs/engineering/tracking-plan.md` lists both and the new properties. - `src/lib/microphone.ts`: the comment no longer claims a denial cannot change; both functions unchanged. **Tracking.** Every existing event keeps its name and properties; the new properties are added alongside: | Event | Added | |---|---| | `brain_dump_started` | `prompt_set` | | `brain_dump_typed_fallback` | `prompt_set` (and `permission_denied` now fires once per visit, not on every retry) | | `brain_dump_completed` | `prompt_set`, `prompts_seen` | | `brain_dump_skipped` | `prompt_set`, `prompts_seen` | | `brain_dump_prompt_advanced` (new) | `via`, `screen`, `prompt_index`, `prompt_set` | | `brain_dump_permission_retried` (new) | `result`, `permission_state`, `via` | #### Where this differs from the brief, and why 1. **Ambient cards from `xl` (1280px), not `md`.** Measured in the browser: between 768 and 1279px the 196px cards collide with the column (with the privacy line at 768, and with the 736px typing composer up to 1279). Below 1280 the rest screen shows the compact live prompt instead, as on phones, and the 12 s rest rotation follows the same breakpoint. Everything at 1280, 1366, 1440 and 1920 is as specified. 2. **The typing screen needed more room than `rows={5}`.** The new typing lead and live prompt add about 130px, which pushed "record instead" below a 1280x720 fold even without the notice (baseline `dev` fits). To keep `dev`'s fit without touching the orb: the typing lead may run to one line (`max-w-2xl`); "Another question" sits on the label row above the composer (CSS grid, so screen readers still get label, question, button); and on screens 800px tall or less the composer has 4 rows (3 with the notice) with tighter gaps. Rest and recording are unchanged and `RecordingControls` keeps `mt-16`. 3. **`permission_state`** on `brain_dump_permission_retried` is the state when the retry began (`prompt` = the user had closed the prompt, `denied` = blocked) for `retry_link`/`record_instead`, and the state it changed to for `permission_change`. Reading it after the answer would mean waiting on the Permissions API between `getUserMedia` and `brain_dump_started`; the answer itself is `result`. 4. **A retry that finds no microphone** (`result: "unavailable"`) drops the "blocked" notice, since the permission is no longer what stops Talk; the existing "We couldn't open your microphone" message shows at rest. 5. **`prompts_seen`** counts the distinct live prompts actually on screen this visit (typing, recording, and rest only where the compact prompt shows), so a desktop skip straight from rest reports 0. 6. **Em dashes.** The insufficient-state copy still has two ("A few sentences about what you do — in any language — …") because the brief says that copy stays as it is. Say the word and I'll change it. No conflict with AGENTS.md. `useBrainDumpStep.ts` (612 lines) and `useBrainDumpRecorder.ts` (392) were already over ~200 lines; the new logic went into its own hooks rather than growing them further, and I didn't refactor them in this PR. **Noticed outside this step, left alone:** none. Inside the step, the textarea placeholder ("What repeats every week? What would you hand off first?") now repeats the new typing lead; the brief keeps the placeholder as is, so I left it. Worth a copy pass later. #### Screenshots Taken from a local harness that renders the real `OnboardingPage` (Back, Skip, progress bar, step dots, Log out) with a seeded session and stubbed API responses, in Chromium. The images live in commit [`4cdcde3`](https://github.com/Significant-Gravitas/AutoGPT/commit/4cdcde3d9876c104d93764786280392c73124e4b) and are removed again in the next commit, so the diff stays code-only. Desktop, rest: Founder/CEO 1440x900, Operations 1280x720, Sales/BD 1366x768 with reduced motion    Desktop, typing: Operations 1280x720, Marketing 1440x900   Desktop, recording: Founder/CEO 1440x900, Operations 1280x720   Blocked microphone, 1280x720: the notice, then the same screen after the site is allowed in its settings (notice gone without a reload, "record instead" back to its usual job)   Prompt closed instead of blocked, 1366x768  Phone 390x844: rest (Founder/CEO), typing, recording, blocked microphone (Operations)  Phone 320x640: rest (Operations), blocked microphone (Founder/CEO)  #### Console event lists Captured at `posthog.capture`, the call `trackBrainDump` makes. Local dev has no PostHog key and no consent manager, so `posthog-js` drops every event there and `posthog.debug()` shows nothing; please re-check with `posthog.debug()` on the preview deploy. Rest → Write → Another question → I'm done (Operations, 1440x900): ``` brain_dump_typed_fallback {"reason":"chose_to_type","prompt_set":"Operations"} brain_dump_prompt_advanced {"via":"click","screen":"typing","prompt_index":5,"prompt_set":"Operations"} brain_dump_completed {"input_mode":"typed","chars":65,"prompt_set":"Operations","prompts_seen":2} ``` Talk → Another question → Send (Operations, 1440x900, fake audio device): ``` brain_dump_started {"prompt_set":"Operations"} brain_dump_prompt_advanced {"via":"click","screen":"recording","prompt_index":5,"prompt_set":"Operations"} brain_dump_completed {"duration_secs":4,"input_mode":"voice","finalize_latency_ms":1508,"prompt_set":"Operations","prompts_seen":2} ``` Skip from rest (Founder/CEO, 390x844, where the compact prompt shows): ``` brain_dump_skipped {"prompt_set":"Founder/CEO","prompts_seen":1} ``` Blocked microphone (permission set to denied): Talk → "record instead" (refused again) → allow the site in its settings → "record instead" → Talk: ``` brain_dump_mic_failed {"error_name":"NotAllowedError"} brain_dump_permission_denied brain_dump_typed_fallback {"reason":"permission_denied","prompt_set":"Operations"} brain_dump_mic_failed {"error_name":"NotAllowedError"} brain_dump_permission_denied brain_dump_permission_retried {"result":"denied","permission_state":"denied","via":"record_instead"} brain_dump_permission_retried {"result":"granted","permission_state":"granted","via":"permission_change"} brain_dump_started {"prompt_set":"Operations"} ``` Prompt closed (permission left at `prompt`): Talk → "try the microphone again" (closed again) → "try the microphone again" (allowed): ``` brain_dump_mic_failed {"error_name":"NotAllowedError"} brain_dump_permission_denied brain_dump_typed_fallback {"reason":"permission_denied","prompt_set":"Operations"} brain_dump_mic_failed {"error_name":"NotAllowedError"} brain_dump_permission_denied brain_dump_permission_retried {"result":"denied","permission_state":"prompt","via":"retry_link"} brain_dump_permission_retried {"result":"granted","permission_state":"prompt","via":"retry_link"} brain_dump_started {"prompt_set":"Operations"} ``` Every name is one of the section 4 events; `brain_dump_typed_fallback {permission_denied}` fires once per visit, and `permission_retried` precedes `started`. #### Review follow-up ( |
||
|
|
2ba900a09b |
feat(platform): guided first run over the real UI, with one-time tips (#16107)
Resolves [SECRT-3149](https://linear.app/autogpt/issue/SECRT-3149). ### Why / What / How **Why.** In our first-user session, a new user clicked through four concept cards ("Meet your Head of AI", …) in seconds, then spent 1.5 hours without finding the wallet, Team or Files, or knowing she could open a second chat. The cards explained ideas but never showed *where* anything is. **What.** The cards are replaced with a short walkthrough over the real screen. It dims the page and lights one real control at a time, with one line each: 1. **Chats:** every task gets its own chat 2. **Team:** experts can take tasks off your plate 3. **Files:** where results are saved 4. **Wallet:** how much allowance is left 5. **First tasks:** "Start here" ends on a suggested task It also adds: - **One-time tips** the first time a feature matters: a second chat, the first cost mention, the first expert proposal. - **Show me around** in the profile menu, to replay the walkthrough any time. - **The wallet in the collapsed sidebar.** New accounts start collapsed, and the wallet was hidden there. **How.** Steps are one list in `FirstRunTour/steps.ts`, pointing at `data-tour-target` attributes on the real UI. Seeing the walkthrough or a tip is saved as an onboarding step (`FIRST_RUN_TOUR`, `CHATS_TIP`, `COST_TIP`, `EXPERT_TIP`), so nothing shows twice. No migration is needed. The walkthrough opens on its own only for someone who has just finished onboarding. ### Screenshots **Walkthrough** (new account, straight after onboarding)      **One-time tip** (collapsed sidebar, after a second chat appears)  ### Changes 🏗️ - Backend: four unrewarded onboarding steps (`FIRST_RUN_TOUR`, `CHATS_TIP`, `COST_TIP`, `EXPERT_TIP`), plus tests. `openapi.json` regenerated. - Frontend: new `FirstRunTour/` (walkthrough) and `ContextualTips/` (tips). The old `OnboardingWelcomeDialog` is removed. - Sidebar: tour targets on chats, Team, Files and the wallet. The wallet now shows in the collapsed rail. - Profile menu: **Show me around**. - The "What's new" popup waits until the walkthrough closes. ### Agents and large language models used - Claude Code with Claude Opus 5.5 (1M context) - Cursor (Auto) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] A new account finishing onboarding gets the walkthrough on Home, over the real sidebar - [x] Skip and "Start here" close it for good. It does not come back on reload or on another device - [x] **Show me around** replays it from the profile menu - [x] Each tip shows once, the first time its moment happens - [x] Works on phone width: the sidebar sheet opens for the sidebar steps - [x] Unit and integration tests for the walkthrough, tips and sidebar pass (30 files, 259 tests). `pnpm lint` and `pnpm types` are clean - [x] E2E `first-run-happy-path.spec.ts` covers sign-up → walkthrough → replay <!-- conductor-workspace-link --> --- [Open workspace in Conductor](https://app.conductor.build/pro-user-8d964eae-256d-46a9-b469-fd3c36fa6673/w/163) --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]> Co-authored-by: Cursor <[email protected]> |
||
|
|
095fbed8cb |
feat(backend): tag every Otto model call with what started the turn (#16221)
### Why / What / How **Why:** we're adding PostHog LLM-judge evaluations (frustrated user, task outcome) that run on a sample of Otto replies and are paid per run. Many replies aren't answers to something a person typed: scheduled runs, subtasks and platform-started turns. Judging those wastes tokens and skews the numbers. The turn's `$ai_trace` event already says what started it (`turn_origin`), but the model calls themselves, which OpenRouter Broadcast sends to PostHog as `$ai_generation`, didn't, so evaluation and insight filters on generations couldn't tell them apart. **What:** every OpenRouter call a turn makes now carries `trace.turn_origin`, which Broadcast delivers as `metadata_turn_origin`. It has the same value as the trace's `turn_origin`: - `chat`: a message someone sent; - `scheduled`: a schedule, or a turn nobody is watching; - `spawned`: a subtask; - `automation`: a turn a workflow's AutoPilot block started, whose prompt is machine-written. The block dispatches like a typed message, so this comes from what dispatched the turn (`entry.tool_name == "autopilot_block"`); a message someone types into the block's chat stays `chat`. Entries queued before `tool_name` existed fall back to the chat's origin; - `system`: started by the platform, not by a message. **How:** - `llm_attribution`: a `_turn_origin` context variable next to the trace id, set with `set_turn_origin()`, sent in `openrouter_attribution()` when set. Outside a turn (no value) nothing is sent. - `turn_tracing`: `_origin` becomes the public `turn_origin(entry, session_origin)`. `TurnTrace.set_origin()` records the value, so the `$ai_trace` event, the Sentry tag and the model calls report the same one. - `executor/processor.py`: once the session is loaded, classifies the turn with the session's origin and sets it for every turn, beside the trace id, so it covers the SDK path (headers built at turn start), the baseline path and the chat title. Before this, an AutoPilot block's turns were reported as `chat` on `$ai_trace` too. ### Changes 🏗️ - `backend/copilot/llm_attribution.py`: `set_turn_origin`, `trace.turn_origin` in the Broadcast body, docstring lists `metadata_turn_origin`. - `backend/copilot/turn_tracing.py`: `turn_origin()` made public, with a docstring of its values. - `backend/copilot/executor/processor.py`: `set_turn_origin(turn_origin(entry))` per turn. - Tests: the origin is sent for each value, not sent outside a turn, and concurrent turns each send their own; `turn_origin` maps a message, the platform, a subtask, a schedule, an unattended turn, the block's turns (legacy chat included), someone typing into the block's chat, and old entries falling back to the chat's origin; the executor sets `chat`, `scheduled` and `automation` (processor_test); the origin the executor sets is the one `$ai_trace` reports. - `docs/engineering/tracking-plan.md`: `metadata_turn_origin` on `$ai_generation`, `automation` on `$ai_trace`. No configuration changes. ### Agents and large language models used - Claude Code with Claude Opus 5.5 (`claude-opus-5-5`) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan: - [x] `pytest backend/copilot/llm_attribution_test.py backend/copilot/turn_tracing_test.py backend/copilot/executor/utils_tracing_test.py`: 38 passed (llm_attribution_test, turn_tracing_test; processor_test runs in CI) - [x] black, isort, ruff and pyright clean on the changed files - [ ] After deploy to dev: PostHog `$ai_generation` events from a chat turn have `metadata_turn_origin = chat`, and a scheduled run's have `scheduled` #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
cb690ed9cc |
ci(platform): let the credits PR run CI and flag advisories no release lists (#16259)
### Why / What / How **Why:** two gaps in the public security sync from #16151. 1. **The credits PR's CI never starts by itself.** The sync opens its credits PR with `GITHUB_TOKEN`. In this repo, every CI run on a PR opened that way waits as `action_required` until a maintainer approves it. #13152, the weekly Anthropic-rates PR, has sat like that since May. So each credits update would need that click before it could even be reviewed. 2. **A forgotten release link goes unnoticed.** An advisory is listed under a release only when its description links that release. A published advisory without the link is left out of every release's notes, with no warning anywhere. **What:** the credits PR is opened by the batch-bot App, so its CI runs like any other PR's. The dry-run summary and the credits PR both name any published advisory that no release lists. **How:** - The credits job mints a batch-bot App token with `actions/create-github-app-token`, scoped to `contents: write` and `pull-requests: write`, the same way `docs-gitbook-sync.yml` does. It mints the token after the files are generated and passes it only to the `create-pull-request` step. The job's own `GITHUB_TOKEN` now has no permissions. - The release writer keeps `GITHUB_TOKEN` on purpose. Release edits made with it never start release workflows, which is what keeps old tags' `release.edited` image builds from firing. - A new `unlinked_advisories(snapshot)` helper returns the published advisories with no release tags. The `credits` command's JSON output carries that list, the credits PR body lists it, and the preview prints it ("none." today). ### Changes 🏗️ - `.github/workflows/security-advisory-sync.yml` (credits job): - The PR token is a batch-bot App token, and the job's own `GITHUB_TOKEN` has no permissions. - The PR body gets a line listing unlinked advisories. - The checklist no longer asks the maintainer to approve CI runs. - `security_public_sync.py`: adds `unlinked_advisories()`; `prepare_credits()` returns it under `unlinked`. - `security_public_publish.py`: the preview names unlinked published advisories, or says none. - `test_security_public_sync.py`: a new test covers the list in the credits output and the preview, including that drafts never appear. The exact-output assertions now include `unlinked`. - `docs/engineering/security-advisory-release-notes.md`: the permissions paragraph explains why each writer uses the token it does, and rollout step 4 and the read-only job's description are updated. ### Agents and large language models used - Claude Code with Claude Opus 5.5: design, implementation and validation. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `python -m unittest discover -s .github/workflows/scripts -p 'test_*.py'`: 101 passed. The security tests also pass on Python 3.10, which "Check PR Status" uses. - [x] Three mutants are each killed by the new test: the inverted filter, the list dropped from the credits output, and the preview always saying none. - [x] Live anonymous dry run against the public API: `credits` reports `"unlinked": []`, and the preview still proposes sections for 19 releases and reports no unlinked advisories. - [x] `actionlint` 1.7.12 is clean on the three security and Classic workflows. The new step's `jq` expression and `errexit` handling were exercised with both empty and non-empty lists and a failing `credits` command. - [x] `pre-commit` (Black, gitleaks, merge-conflict and debug checks) on the changed files. - [ ] Live App-token PR creation: this runs only after the variables are enabled on `master`. The batch-bot App already opens and updates PRs here (`batch-command-handler.yml` uses `permission-pull-requests: write`). #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**). It uses the existing `BATCH_BOT_APP_ID` variable and `BATCH_BOT_PRIVATE_KEY` secret; nothing new. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]> |
||
|
|
f66554c508 |
feat(platform): add Cloudflare to the MCP catalog (#16217)
### Why / What / How **Why:** Cloudflare publishes an official hosted MCP server, `https://mcp.cloudflare.com/mcp`, covering its whole API (about 2,500 endpoints) through four Code Mode tools. It isn't in our catalog. Listing it with our default scope discovery wouldn't be usable: its protected resource asks only for `user:read account:read` and expects clients to add the rest through step-up authorization (`403 insufficient_scope`), which our client doesn't do. Cloudflare's consent screen would open on its Minimal preset, and every `execute` call would be refused. **What:** Add Cloudflare to the MCP catalog, plus a catalog flag that makes sign-in request every scope the authorization server advertises. Cloudflare's consent screen then opens on **Full access**. Users can still narrow it there, but the setup copy tells them to keep it, because the approval gate, not a smaller grant, decides which calls ask first. **How:** - `oauth_request_all_scopes` is a backend-only `MCPServerMetadata` field, kept out of the API like `legacy_server_urls`. With it set, `/oauth/login` requests the authorization server's `scopes_supported` (403 scopes today) instead of the resource's minimum. Cloudflare's consent page preselects whichever preset exactly matches the requested scopes, which here is Full access. - Why not pin the scope list: Cloudflare refuses unknown scopes with `invalid_scope`, and its list changes. The 2026-10-20 sync ([cloudflare/mcp#252](https://github.com/cloudflare/mcp/pull/252)) removed `workers-scripts.bind`, which would have broken sign-in for everyone. Reading the advertised list at sign-in can't go stale. - Writes stay gated by the effect map, following #15199's rule that catalogued servers connect with read and write access. `docs`, `search` and `whoami` are mapped as read. `execute` runs JavaScript against the live API and carries every call, reads included, so it is mapped as external, like Sentry's catch-all `execute_sentry_tool`. It asks before each call in Ask First and Auto until the user sets a rule, and "Let Otto judge" sees the code it would run. Irreversible would have badged every read "Can't be undone" and kept those cards out of Approve all and quick approval, which is friction with no added safety. ### Changes 🏗️ - `mcp_catalog.json`: `mcp_cloudflare` (hosted, sign-in or a Cloudflare API token as bearer, `oauth_request_all_scopes`). Setup copy says to keep Full access on Cloudflare's consent screen, warns that Minimal allows only identity, and covers the Account Resources: Read permission account tokens need and IP-filtered tokens being unsupported. - `mcp_catalog.py`: the flag. It requires OAuth and can't be combined with `oauth_scopes`. - `mcp/oauth_registration.py`, `mcp/routes.py`: `requested_scopes()` now holds the login route's scope choice. The order is unchanged (the caller's scopes, then the catalog's, then the resource's, then the authorization server's); the flag adds one branch. - `mcp_effects.json`: Cloudflare's tool map, sourced from the [cloudflare/mcp README](https://github.com/cloudflare/mcp/blob/main/README.md) and its tool registrations. - `public/integrations/cloudflare.png`: Cloudflare's developer docs favicon, unmodified (96×96), recorded in that folder's README. - `docs/engineering/hosted-mcp-catalog.md`: documents the flag and effect maps, and drops the "Allow changes" write-scope guidance retired in #15199. - No configuration changes: Cloudflare supports dynamic client registration, so no client ID or secret is needed. Notes for reviewers: - With `COPILOT_AUTO_MODE` off, catalogued servers skip MCP review, so `execute` runs without asking, the same as every catalogued write tool today. With it on, `execute` asks first in Ask First and Auto, and runs in Unsupervised. - If a user narrows the grant on Cloudflare's screen, calls beyond it come back as the existing 403 "refused" card. Reconnecting asks for everything again. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] The new tests failed first: the Cloudflare entry and effect tests against `dev`'s catalog (4 failures), and the advertised-scopes login test before the flag existed. - [x] Backend: everything in `backend/api/features/mcp/` plus the catalog, logo and provider route tests: 345 passed. The other catalog-dependent suites also pass: expert credentials, integrations router, capability registry and benchmark, gate review, capability tools, and service identity. - [x] Frontend: `scripts/integration-logos.test.ts` and `settings/integrations/__tests__/mcp-catalog.test.tsx`: 78 passed. - [x] `poetry run lint --skip-pyright` is clean, and whole-backend `pyright` reports 0 errors. The exported `openapi.json` is byte-identical, so no client regeneration is needed. - [x] Live against Cloudflare: the real `/oauth/login` route (only the state store and auth stubbed) discovered the metadata, registered a client through DCR, and requested all 403 advertised scopes in an 8.5 KB authorize URL. Cloudflare's consent page returned 200 with the Full access preset selected. - [x] Live sign-in with a real Cloudflare account through the real `/oauth/login` and `/oauth/callback` routes (only the state store and credential storage stubbed). Consent opened on Full access, and Cloudflare's own screen granted 402 of the 403 scopes: it leaves the sensitive Account API Tokens Write off by default. The callback's issuer check and token exchange passed and returned a refresh token. With the platform's MCP client on protocol 2026-07-28, `tools/list` returned exactly `docs`, `execute`, `search` and `whoami`, the names in the effect map. `whoami` and `docs` answered, and `execute` ran two GET requests (accounts and zones). A refresh rotated both tokens and calls kept working; after both tokens were revoked, calls were refused. #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]> |
||
|
|
b7b75494f4 |
feat(backend/api): External API v2 (#12206)
Adds v2 of the platform's public API at `/api/v2`: REST endpoints for a user's graphs, library agents, folders, presets and webhook triggers, runs and their human-in-the-loop reviews, schedules, workspace files, credits, credentials and marketplace submissions, plus an MCP server exposing AutoPilot's tools, all callable with an API key or an OAuth token and gated by per-resource scopes. - Resolves #11678 - Partly based on #12284 ### Why - v1 can create and execute a graph, execute a block, read the marketplace and manage integration credentials, and nothing else. - An app signing users in through OAuth (#11617) cannot list their agents, run a saved preset, set up a webhook trigger, follow, stop or share a run, answer a review, schedule a run, or read their files and spend through v1, so it cannot use the platform as its backend. ### What New surface, in `backend.api.external.v2` (too much to review whole; read `mcp_server.py`, `mcp_calls.py`, `mcp_tenancy.py` and `library/presets.py`, and scan `models.py`): - Resources: `/graphs`, `/library/agents`, `/library/folders`, `/library/presets`, `/runs`, `/runs/reviews`, `/schedules`, `/files`, `/credits`, `/integrations/credentials`, `/marketplace`, `/blocks`, `/search`, `/me` - MCP server at `/api/v2/mcp`, with its RFC 9728 metadata at `/.well-known/oauth-protected-resource/api/v2/mcp`: AutoPilot tools that opt in through `allow_external_use`; `tools/list` shows only the tools the caller's scopes allow; a refused or failed call returns an `isError` result - 13 new `APIKeyPermission` scopes, one per resource and verb (`schema.prisma`, migration `20261008140000_add_v2_api_key_permissions`; `READ_STORE` was already on `dev`), each described on the OAuth consent screen and in the API key dialog - v1 stays at `/external-api/v1`, deprecated: it stops working on 2027-02-01, after its last day of service on 2027-01-31 - v2 guides under `docs/platform/integrating/`, and the v2 OpenAPI spec published to GitBook from `master` (`docs-gitbook-openapi.yml`) Who can call what: - Every route resolves its credential to one organization and team; a row in another organization is a 404, over REST and MCP alike (`mcp_tenancy.py` checks every id argument of every exposed tool before it runs). - A banned account's API key or OAuth token is refused on every path: `X-API-Key`, an API key sent as a Bearer token (how MCP clients send one), an OAuth token, and the MCP server's own token check. - A graph someone else owns, a colleague's or a marketplace agent's, is read as its shared copy, as on the internal routes: no owner ids, credential references or secret inputs. - A colleague's run or graph in the caller's organization is readable but not writable: `DELETE /runs/{id}`, `POST /runs/{id}/stop`, `PUT /graphs/{id}` and `PUT /graphs/{id}/versions/active` answer 403 before acting. - An account without an active plan gets a 402 (`payment_required`) from every route that starts work, stores files or calls out, and from every MCP tool call. Reads, billing, deletes, stopping a run, sharing and organising stay open, as on the internal API since #15676. - MCP tool calls run outside the action approval gate: an MCP session is an automation's, so nobody is asked to approve a card, and the credential's scopes are the authorization, as in REST. A run an MCP call starts is tagged an API run, as v1's are, so its reviews come back through `/runs/reviews` rather than a chat that was never saved. Runs, presets and triggers: - `POST /library/agents/{id}/runs` and `POST /library/presets/{id}/execute` take an `Idempotency-Key`: a repeat returns the run the first request started, a repeat while that one is in flight is a 409, and the key reused on another agent, preset or body is a 422. - Both answer 422 for an input the graph does not have, or a required one omitted or `null`, with one `details.errors` entry per input (for a preset, unless it is a trigger, whose inputs are its block's settings); `POST /schedules` does the same, and schedules the active version when the request names none. - Presets: list, get, create, update, delete and execute. A preset must name a graph in the key's organization. One created for a graph that a single hired expert installed belongs to that expert, as on the internal routes, and so do triggers and schedules. Executing one runs for the expert it belongs to (409 while that expert is paused) and lands in the preset's organization. - Webhook triggers: `POST /library/presets/setup-trigger` registers the webhook and returns `webhook_url`, where a hand-configured service sends its events. New trigger `inputs` or `credentials_inputs` re-register the webhook, and whichever a PATCH leaves out keeps its saved value. Deleting a trigger removes its webhook once nothing else uses it. - A trigger's secret settings (a generic webhook's `secret_token`, All Quiet's `signing_secret`) are returned only when the trigger is created. Later reads leave them out of `inputs` and list them in `hidden_inputs`, and an update that leaves them out keeps them. - An active trigger starts runs, so arming one needs `RUN_AGENT` as well as `WRITE_LIBRARY`: `setup-trigger`; a PATCH that sets `is_active` to true, or gives a trigger that stays active new inputs or credentials (which re-register its webhook); and creating an active preset of an agent with a webhook trigger. Over MCP, `update_preset` needs it for `is_active: true`, and for new `inputs` unless they come with `is_active: false`, since the tool can't see the graph before it runs. - Over MCP: `list_presets`, `update_preset`, `delete_preset`, `list_agent_triggers` and `setup_agent_webhook_trigger`, scoped to the key's organization as above. Everything else: - `POST /schedules` creates a schedule for the `graph_id` in its body. - `POST /files/upload` writes to the workspace `GET /files` lists, and returns the file's id and a `workspace://` URI. `GET /files` lists the user's files, not Otto's working files or skill packages, as the Files page does. - Every response carries `X-RateLimit-Limit`, `-Remaining` and `-Reset`, and a 429 adds `Retry-After` (none when Redis is unreachable). An API key is counted per client and key head before it is hashed, and a request is verified once; a limiter that fails open logs only its own name, never the bucket. - A v2 request body is capped at 10 MB, MCP included; an upload gets its file cap plus 1 MB of framing, and a marketplace media upload past 10 MB gets a 413 as soon as it passes the cap. - `POST /runs/{id}/reviews` takes `auto_approve_future`, and answers 404 for an unknown review and 409 for a run that is not awaiting review. - One spelling per concept: `inputs`, `credentials_inputs`, amounts in `_cents`, durations in `_seconds`, times as ISO datetimes; one `ErrorResponse` body on every error. MCP results and 500s carry no internal exception text. - `POST /graphs` and `PUT /graphs/{id}` validate a version's credentials only when it is saved active. Activating a version moves the graph's webhook presets onto it only if the credential also has `RUN_AGENT`. - The served `openapi.json` lists only the declared `servers` (`root_path_in_servers=False`), not the mount path FastAPI would put first. Changes to existing code: > [!NOTE] > For an overview of all changes to the internal API, check out [`autogpt_platform/frontend/src/app/api/openapi.json`](https://github.com/Significant-Gravitas/AutoGPT/pull/12206/changes#diff-a1bb1783d6023599f9aaba5414bfd0b49637b61dad8da09c7bed1b2ee01ba852) - The external middleware resolves every credential in one `resolve_auth_info`, verified at most once per request, and refuses a banned owner's on each form; `require_api_key` and `require_access_token` are gone. - The internal upload route and the internal review route call the services v2 uses: `workspace/service.py` and `graph_executions/review/service.py`. The review service carries #15564's step-card handling and #15062's chat-queue wake. - `workspace/service.py`'s size-capped reader is public (`read_within_size_limit`), and refuses before reading when the multipart parser already counted the size. - The copilot's `run_agent` checks inputs with `unknown_inputs` and `missing_inputs` (`data/graph.py`), which v2 shares; a required input sent as `null` now counts as missing there too. - `db.update_preset` stores a preset's inputs and credentials in the same rows, and now rewrites them whenever both are given, either of them possibly `{}`. Before, it refused new inputs beside empty credentials, which the v2 PATCH and the copilot's `update_preset` tool both send for a preset that needs none, and it silently kept the old inputs when every input was cleared. `update_triggered_preset` prunes a webhook it registered for an update that then fails, unless it is the one the trigger already had. - The copilot's preset and trigger tools scope their listings, and the library entry a trigger setup creates, to an External API caller's organization; in a chat they behave as before. - `UserTransaction` is renamed `UserCreditTransactionAdminView` (admin credit routes and the admin spending page). - The Redis fixed-window rate limiter is extracted from `search/rate_limit.py` into `api/utils/rate_limit.py`, which v2 shares; #15703's public store-search limiter sits beside it. Its Redis round trip is bounded at 0.25 s and fails open past it, because every v2 request waits on several of these checks. - The store cache's helpers are public (`get_cached_store_agents` and the rest), for v1 and v2. - New setting `TRUSTED_PROXY_COUNT` (default 3), commented in `backend/.env.default`. - The API key dialog lists permissions in three groups, "API v2", "API v1 and v2" and "API v1 only (stops working 2027-02-01)", each with the consent screen's description, and a key's info dialog marks its v1-only permissions. The descriptions and each permission's API version live in `lib/api-key-scopes.ts`, which the consent page reads too; both are typed `Record<APIKeyPermission, ...>`, so a new permission fails tsc until it is described and classified. - `oauth-tool` offers every permission, v2's included, read from the enum. - Library data layer: `list_library_agents` takes `published` and `favorite` filters, and matches `published` by the graph's live, approved listing; `get_library_agent` fetches the marketplace listing with its active version and creator profile in one query; `add_store_agent_to_library` drops unneeded queries. - HITL data layer: `get_reviews` with optional filters; `get_pending_reviews_for_user` and `get_pending_reviews_for_execution` wrap it; `graph_runs_only` leaves out AutoPilot chat reviews. - `get_graph_all_versions` takes `include_subgraphs` and `offset`; `get_active_graph_version` returns a graph's active version number, and `get_graph(version=None)` keeps returning the newest. - `delete_store_submission_or_raise` raises why a submission couldn't be deleted; `delete_store_submission` wraps it and keeps answering true or false. - `GraphSettings.from_graph()` and `update_library_agent_version_and_settings` are removed (replaced by `GraphSettings()` and `update_agent_version_in_library`). - `PATCH /api/store/profile` accepts partial updates; `Profile` is replaced by `ProfileUpdateRequest`. `PATCH /library/agents/{id}` with `graph_version` points an entry only at the caller's own version in the key's organization, or at another user's version approved in the marketplace. - The consent page's Deny button does nothing until the app's info has loaded, which happens only once the backend has accepted the `redirect_uri`; the rest of that fix reached `dev` with #15057. Two tests pin the app-info endpoint's `redirect_uri` check. - `add_auth_responses_to_openapi` works with any auth scheme; exception handler setup is extracted from `rest_api` into `api/utils/exceptions.py:add_exception_handlers`; `export-api-schema` exports the external APIs' OpenAPI schemas too. - `# pragma: allowlist secret` on existing lines the secret scanner flagged; fast `pre-commit` checks run before slow ones. ### How - v2 is its own FastAPI app, mounted on the REST app at `/api/v2` beside the internal routes; v1 stays under `/external-api` (`backend.api.external.fastapi_app`). Each has its own `/docs`, and neither serves metrics, which the REST process serves on its own port. `rest_api_test.py` keeps v2 and the internal API apart: no internal route may start with `/api/v<n>`, and every v2 path sent to the host app must reach the v2 mount rather than an internal route with a path parameter. - Every v2 route depends on `require_permission(<scope>)`, which resolves the API key or OAuth token to a `TenantContext` (user, organization, team). A route that needs a plan depends on `require_paid_permission(<scope>)` instead, which adds the internal API's `enforce_payment_paywall`. v2's own `paywall_routes_test.py` fails on a route that neither checks nor sits on its open list. - An MCP call is held to the credential before the tool runs (`mcp_calls.py`): unknown arguments are refused, an argument that does more than the tool's own permission needs the matching one (`external_permissions`), ids are checked against the organization, and a tool that bills the platform runs the paywall and usage-cap checks. - The MCP server is served through `MCPMount`, which the platform app's lifespan runs (a mounted app's lifespan never runs); it answers 503 before startup. - The preset routes call the internal API's trigger helpers (`library/triggers.py`), so webhook registration and cleanup are one implementation. The hidden trigger settings are read from the trigger blocks' own inputs marked `secret`, so a new trigger block is covered without a list to maintain. - The anonymous rate-limit bucket keys on the `X-Forwarded-For` entry our own proxies appended, `TRUSTED_PROXY_COUNT` (default 3: Cloudflare, then Google's load balancer) places from the right; `0` uses the socket peer. - Idempotency keys live in Redis, held per user, organization and team, where the run lands. A key that can't be claimed is a retryable 503; an in-flight key holds for 24 hours, so a request that dies after its run started never lets a retry start a second one. - `contract_shape_test.py` checks the published OpenAPI document, so a later model cannot reintroduce a retired spelling or an amount without its unit. ### Known gaps - `pause_schedule` and `resume_schedule` are not exposed over MCP pending a decision on exposing them under `WRITE_SCHEDULE`. - MCP OAuth authorization-server discovery (RFC 8414) is not published: without dynamic client registration, and with a token endpoint that takes JSON rather than a form, it would advertise a flow a conformant client cannot complete. - `/search` still counts the user's library agents in other organizations in `total_count`; it needs the organization in the shared hybrid-search query. - One valid key presented from many addresses still costs a hash per request, up to 300 a minute per address. - The internal API and v1 have no request body cap; this PR caps v2 only. - v2's requests are counted under the REST API's metrics (`autogpt_rest_api_*`, handlers `/api/v2/...`), not the external API's, so the dashboard's External API panels show v1 only until a separate infra change adds v2's handlers to them. That change also routes `/api/v2` on `dev-api.agpt.co`, which routes only `/external-api` today. ### Verified On `7b1b7cf192` I ran `backend/api/external/`, the library, review, search and store suites, all of `backend/copilot/tools/`, `architecture_test.py`, both paywall route tests and `test_block.py`: 8071 passed, 84 skipped. What did not pass is this machine's, not this branch's: the store skill suites refuse a database that already holds listings (62 errors at setup), the browser tests need Chromium (5), `test_ensure_content_embedding_blocks` fails on clean `dev` here, and `skill_file_routes_test.py` passes alone (9 of 9). pyright is clean over the backend, and the frontend type-checks and its auth page tests pass (45). The commits after it (two test-only fixes, the rate-limit warning, the file listing, the shared graph copy and the MCP run's tagging) were checked with `backend/api/external/`, `copilot/gate/`, the graph routes, `run_agent`'s tests and the executor's. Each new guard turned its test red when removed: the ban check on each of the four credential paths, the hidden trigger settings on read and on update, the `RUN_AGENT` requirement over REST and MCP, the rate-limit warning naming its bucket, Otto's files in the listing, a non-owner's graph read whole, and an MCP session made interactive, which parks an approval. None of this ran against a live stack. On `20e9247c22`, which moves v2 to `/api/v2`, groups the dialog's permissions and moves v1's end date: `backend/api/external/`, both paywall route tests, `rest_api_test.py`, `public_metrics_test.py` and `architecture_test.py` (472 passed), `test_block.py` (3714 passed, 84 skipped), and the API key, consent and profile frontend suites (135 passed), with tsc clean. The two route guards each went red on a mutation (an internal `/api/v3` route; an internal `/api/{x}/library/agents`, which answered `GET /api/v2/library/agents`), as did the dialog tests and the tsc check on the version map. A REST server started from this tree served `/api/v2/openapi.json` and `/api/v2/docs`, answered `/api/v2/me` and `/api/v2/mcp/` with 401s pointing at the new discovery document, served that document, still served v1 at `/external-api/v1`, and answered 404 at `/external-api/v2`. On `f4db212b6b`, which fixes the preset update: `backend/api/features/library`, `backend/api/external/`, the copilot's preset tool tests, the webhook ingress tests and `architecture_test.py` (680 passed), and `test_block.py` (3714 passed, 84 skipped). The two PATCH tests drive the real `db.update_preset` and went red with the old guard; the prune went red when removed and when it pruned the trigger's own webhook; and the tests added for a listing's and an update's withheld trigger secrets and a trigger's run each went red when that code was reverted. On `61dd619efb`, which merges `dev` at `ea588f7375` (#15057's redirect checks and client-id encoding, #16175's invitation-only membership): the consent page conflicted, and keeps the shared descriptions module, the Deny guard, `dev`'s `encodeURIComponent` on the app-info request and `dev`'s test file. The internal spec equals a fresh export. `backend/api/external/`, the library, OAuth, orgs, paywall, `rest_api` and public-metrics tests, the copilot's preset tool tests and `architecture_test.py` passed (860), as did `test_block.py` (3714 passed, 84 skipped) and the consent, setup-wizard, proxy and API key frontend suites (324), with tsc clean. On `76ea8cf9e9` through `0a63dac8cf`, which close the remaining review findings, each fix has a test that goes red without it, through the real route, or the real `db.update_preset` where the bug sat between layers: credentials-only and clear-all PATCHes; a limiter whose Redis never answers; an idempotency key reused under another team; expert attribution on presets, triggers and schedules; a key holding only `WRITE_LIBRARY` refused when it arms an active trigger by PATCH or creates an active trigger preset, and the MCP equivalent; a failed attach pruning its webhook; an MCP tool failure returning no internal text; and a team-pinned key stopping once its membership ends. On `0a63dac8cf`, `backend/api/external/`, the library suites, the copilot's preset and trigger tool tests, both paywall route tests and `architecture_test.py` passed (703), as did `test_block.py` (3714 passed, 84 skipped). ### Agents and large language models used - Claude Code with Claude Opus 5.5 (implementation, merges, tests, docs) - T3 Code delegated reviewers for #15376's security review rounds: Claude Mythos 5.1 and Claude Fable 5.1 (Claude), GPT Daybreak Blue (Codex) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] The `backend/api/external/` package and the library, graphs, review, workspace and copilot tool suites, on this head - [x] Every v2 route's scope and tenant check (`permissions_test.py`, `tenancy_test.py`), and every MCP tool's id arguments (`mcp_tenancy_test.py`) - [x] The published OpenAPI contract (`contract_test.py`, `contract_shape_test.py`) #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Nicholas Tindle <[email protected]> Co-authored-by: Nicholas Tindle <[email protected]> |
||
|
|
9fffbaf537 |
ci(platform): sync public security credits and release notes (#16151)
### Why / What / How **Why:** our public researcher credits and each release's security notes drift from the published advisories. Advisories get published after the release that fixed them, credits get accepted later, and records get corrected or withdrawn. `SECURITY.md`'s list was last edited by hand in September, and no release note links its advisories. Separately, the Classic Docker release job runs on every Platform release. Its `startsWith(github.ref, 'refs/tags/autogpt-')` filter matches `autogpt-platform-beta-v*`, so it has started on every Platform release since at least v0.6.69, and again whenever a maintainer edits release notes (`types: [published, edited]`; v0.7.1 and v0.7.4 each ran twice). Every run failed only because `Dockerfile.autogpt` no longer exists. A successful build would have pushed `significantgravitas/auto-gpt:latest` plus a Platform-numbered tag over the Classic image. **What:** a scheduled sync that keeps the `SECURITY.md` acknowledgments and a "Security fixes" section in each release's notes in step with the published advisories. It also limits the Classic Docker release job to published Classic tags. **How:** the sync never has access to private reports. - It reads advisories anonymously and keeps four identifier fields per advisory in a committed public snapshot: GHSA ID, CVE ID, accepted researcher handles and release tags. - A read-only job validates the data and previews the exact sections a run would write. - Two writers are each off until their own variable is set: one opens a credits PR against `dev`, the other edits only the managed block of existing releases. - Both writers run pinned `master` code, re-fetch the public data and stop if it changed since the read-only job. ### Changes 🏗️ - **Security sync** (`.github/workflows/security-advisory-sync.yml`, every six hours, plus a manual dry run). Scripts: - `security_public_api.py`: anonymous, bounded HTTPS reads. No auth, proxy or redirects. Failures log only a fixed reason. - `security_public_sync.py`: the four-field projection, the generated `SECURITY.md` block and the release section. - `security_public_publish.py`: preview, or the release-body update. - `security_release_notes.py`: credit consent and the release each advisory was fixed in. - **Release matching:** an advisory is listed under a release when its description links that release's tag URL *and* one of its package rows gives that release's version as patched. A hidden `security-release` mapping is needed only when the rows report different patched versions. - **Release writer:** preserves every note outside the managed block, from a fresh read. It sends `make_latest` on every edit: `true` for the current Latest release, `false` for the rest. It also checks `releases/latest` before the first edit and after the last. - **`SECURITY.md`:** - Generated credits, keeping each researcher's chosen name beside their handle where they gave one. - No team members and no stale per-person counts. - The one acknowledgment with no published advisory credit is kept as historical. - **Classic Docker release workflow:** `types: [published]`, a `refs/tags/autogpt-v` filter and `permissions: contents: read`. - **CI:** `security-advisory-sync-test.yml` runs the tests against the PR head. The required "Check PR Status" job runs them only from the base commit. - **Runbook:** `docs/engineering/security-advisory-release-notes.md` covers rollout, duplicate consolidation and Docker package metadata. **Takeover changes (since `0fdc05b`):** - Advisories whose package rows agree on the fixed version are now matched to their release. Before, an advisory with a Platform row and a Docker image row, both patched in `0.9.0`, made the projection raise `RELEASE_MAPPING`. The reader job runs that projection, so publishing one advisory in that format would have stopped every run until someone added hidden comments by hand. - The release writer now keeps the current Latest release. GitHub documents `make_latest` as defaulting to `true` on updates. The first run edits 19 older releases, newest first and ending on v0.4.3, while v0.9.0 is Latest. - Removed the manual release-note helper. It was a second writer for the same block (with titles), and the next scheduled run would have replaced its output. Its tests now drive the automated path. - Restored the chosen names of 16 researchers, such as "Joshua Rogers (`@MegaManSec`)", and left the team's handles out of the acknowledgments (`TEAM_LOGINS`). `SECURITY.md` and the snapshot are regenerated from live public data. - Dropped the mention of a `PUBLIC_SECURITY_SYNC_ENABLED` switch that `dev` never had, and merged `dev`. **Rollout:** 1. Merge through `dev` to `master` with both writer variables unset. 2. Dispatch a dry run and review the job summary. 3. Set `PUBLIC_SECURITY_CREDITS_ENABLED=true`. CI on the PR it opens waits for a maintainer to approve the workflow runs, as it does on other `GITHUB_TOKEN`-created PRs here. 4. After reviewing the preview, set `PUBLIC_SECURITY_RELEASE_NOTES_ENABLED=true`. A manual release-note edit made during the writer's run can still be overwritten, because GitHub has no conditional PATCH for releases. The guide says so. ### Agents and large language models used - Codex with GPT-6 Astra: the original implementation and its review rounds. - Claude Code with Claude Opus 5.5: the takeover design review, the changes listed under "Takeover changes", and the validation below. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `python -m unittest discover -s .github/workflows/scripts -p 'test_*.py'`: 98 passed on Python 3.11. The security tests also pass on Python 3.10, which the required "Check PR Status" job uses. - [x] Reproduced the two-row failure with the PR's own `public_snapshot` before the fix (`RELEASE_MAPPING`); passes after. - [x] 11 mutants of the new code, each killed by its own failing assertions: - the two-row rule; - `make_latest` in the payload; - which release claims Latest; - the Latest pre-read and post-check; - the team filter and its case-insensitive match; - name rendering. - [x] Live anonymous run against the public API: - `digest` and `credits` regenerate the committed `SECURITY.md` and snapshot byte for byte. - `--preview` proposes sections for 19 releases, with the chosen names and no team handles. - The current Latest release is v0.9.0. - [x] `pre-commit` (Black, gitleaks, merge-conflict and debug checks) on the changed files. - [ ] Live write path during rollout: no variables set and no release edited during development. #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**): two repository variables, both unset by default. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: GPT-6 Astra (Codex) <[email protected]> Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]> |
||
|
|
c9d11c8cd2 |
fix(frontend/onboarding): say so when the brain dump's microphone can't open, and offer Write (#16067)
The onboarding brain-dump step now tells the user when their microphone can't open, and points them to Write, instead of doing nothing when they click Talk. Resolves #16064 ### Why / What / How **Why:** A new user on the brain-dump step clicks Talk on a desktop with no microphone, or while another app holds it. Nothing happens: no message, no change on screen, and nothing recorded. Only a denied permission was handled; it switches the step to typing. Every other failure (`NotFoundError`, `NotReadableError`, `OverconstrainedError`, or a `TypeError` when the page has no `navigator.mediaDevices`) returned silently, so the user kept clicking a button that could never work, and we could not count how often it happened. **What:** - When the microphone fails for any reason but a denied permission, the step stays where it is and shows "We couldn't open your microphone. You can write it instead." beneath the buttons. Write becomes the primary button, and Talk stays, because a microphone that is missing or busy can be fixed on the device. - Every failure to open the microphone is recorded as `brain_dump_mic_failed` with the browser's `error_name`. A denied permission still also records `brain_dump_permission_denied`, so that funnel keeps its meaning. - A microphone that opens but cannot be recorded from (the browser refuses to build or start the recorder) is treated the same way, and the microphone is released instead of staying open. - A denied permission behaves as before. **How:** `useBrainDumpRecorder.openMicAndRecord` records the event for every `getUserMedia` failure, and for a `MediaRecorder` that throws while being built or started (after stopping the tracks), and sets a new `micUnavailable` state for anything but a denial; a successful open clears it. `BrainDumpStep` renders the sentence under `RestActions`, which takes a `writeFirst` prop to swap the two buttons' emphasis. The "was the permission denied?" check was written out three times: the brain-dump helpers, the chat composer's `useVoiceRecording`, and voice mode's `useVoiceMode`. It is now one function, `isMicrophonePermissionDenied` in `lib/microphone.ts`, used by all three, beside `microphoneErrorName` for the event. The composer and voice mode behave exactly as before. The event is added to `posthog-events.ts`, its name test and `docs/engineering/tracking-plan.md`. Borderline decisions, settled here: - The step stays on Talk and Write rather than switching to typing as a denial does: a missing or busy microphone can be fixed, a denial cannot. - `brain_dump_mic_failed` also fires on a denial, so one event counts every failure; `brain_dump_permission_denied` is kept beside it. ### Changes 🏗️ - `lib/microphone.ts` (new) with its test, moved from the brain-dump helpers' test. - `onboarding/steps/BrainDumpStep/useBrainDumpRecorder.ts`, `useBrainDumpStep.ts`, `BrainDumpStep.tsx`, `components/RestActions.tsx`: the event, the state, the sentence and the button emphasis. - `copilot/components/ChatInput/useVoiceRecording.ts`, `copilot/voice/useVoiceMode.ts`: use the shared check. - `services/analytics/posthog-events.ts` and its test, `docs/engineering/tracking-plan.md`: the new event. - `onboarding/__tests__/brain-dump.test.tsx`: a page test, run for `NotFoundError`, `NotReadableError` and a `TypeError`, that clicks Talk and finds the sentence, Write and Talk, and the event with the error's name, and no permission-denied event; and a recorder test, run for a `MediaRecorder` that throws in its constructor and in `start()`, that the tracks are stopped, the state is set and the event fires. Verified: I ran the 94 test files under `onboarding/`, `lib/microphone`, `services/analytics`, the composer's `ChatInput/`, `copilot/voice/` and `dev/brain-dump-debug/` (1,148 passed), with `pnpm types` and eslint clean. Two mutations each turned all three cases of the page test red: going back to the silent `return false`, and dropping the event. Leaving the recorder's construction and start unguarded turns both cases of the recorder test red (the 34 files under `onboarding/`, `dev/brain-dump-debug/` and `lib/microphone` pass, 472 tests). I did not click through the step with a real missing microphone. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] No microphone, a busy microphone and a missing `mediaDevices`: the sentence, Write and Talk show, and the event carries the error's name - [x] A denied permission still opens the typed composer (existing test) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]> |
||
|
|
7f91b94deb |
feat(backend/copilot): the read-injection check gets its own flag and a shadow mode that judges every read and holds nothing (#15549)
### Why / What / How **Why:** the prompt-injection check on outside reads (the read screen) only runs where `copilot-auto-mode` is on, which today is one account on Dev. So there is no data on how often it would hold a read. It also can't be rolled out or measured apart from the action approvals that made auto mode too noisy to keep on. Resolves #15542 **What:** the read screen gets its own flag, `copilot-read-screen`, and a shadow mode. - `true` (or the variant `"enforce"`): a flagged read is held for the user's approval, as today. - `"shadow"`: every outside read is handed to the model as usual and judged in a background task. Nothing is held, and the verdict becomes one PostHog event, `chat_read_screen_shadow_decision`. No content, URL or judge wording is sent. - `false` or missing: off. `copilot-auto-mode` stays a boolean and still drives the action gate, mode persistence, the prompt supplement and spend metering, exactly as on dev. The action gate itself is unchanged. **How:** - `reads.screen_mode()` reads the new flag through `get_flag_variant`. That function is new in the flag layer: it applies the env override first, as `is_feature_enabled` does, then returns the vendor's raw answer, so a variant reaches the caller. The screen runs only where a signed-in user can answer a card (`answerable()`, now shared with the action gate's `gate_active`). `screen_read` and `release_held_read` both follow the new flag, so a held read is still released when auto mode is off. - Shadow starts the judgement after the read is handed over, through the same `_judge` enforce uses: the producer's declared outside parts first, then `judge_content`. At most 64 judgements are in flight per process; past that, a judgement is dropped and counted (`copilot_read_screen_shadow_skipped_total`). A judge that raises or answers nothing usable is recorded as `judge_error`. - The content judge now reports which stage decided (Jev or the LLM) and a fixed code beside the quoted passage: `clean`, `failed`, `instructions` for an LLM hold, or, on a Jev hold, the question Jev rated likeliest (`instructs_agent`, `claims_authority`, `assigns_task`). The event carries only those codes. - `CHAT_READ_SCREEN_SHADOW_SAMPLE_RATE` (default 1.0) samples whole chats by a hash of the session id. Changing it needs a config change and a restart; the flag's percentage rollout is the lever that needs no deploy. - Storing the judged text for tuning is not in this PR, and needs a privacy decision first. `shadow._record` has each event's `judgement_id` for a later store to key on. **Flag setup.** `copilot-read-screen` exists before this merges, so nothing changes when it lands. Until now the read screen ran wherever `copilot-auto-mode` was on, and the new flag mirrors that targeting. - On Dev it's on for the same account as `copilot-auto-mode`, serving `true` (enforce). - On prod it's inactive, as `copilot-auto-mode` is there. - Both backends read flags from PostHog: Dev since #15583, prod since the infra switch on 2026-10-16. The frontend never reads this flag. - To measure, serve the `shadow` variant to whoever should be measured. That makes the flag multivariate, and a multivariate flag serves variant keys, never `true`, so a cohort meant to keep enforcing gets the `enforce` variant. Known limit: with the read screen on and auto mode off, the model doesn't get the approval rules in its prompt, which come with auto mode. A held read's stub still says what the model needs: the content arrives later as a `<held_call_result>`, and it should carry on without fetching it another way. ### Cost Per call. The measured figures come from the content-judge measurement of 2026-09-25, at list prices; the computed ones use today's rubric size and list prices. | stage | runs on | cost per call | latency | | -------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------- | | Jev | every outside text read, per chunk (at most 10) | measured ~$0.0002 per 60k-character page | measured p50 0.26 s | | Sonnet 5 | a Jev hold (to quote it), every image read, every read without a Jev key | computed $0.005–$0.02 (rubric + chunk at $2/M, up to 1,024 output tokens at $10/M) | not measured here | Per turn (computed): with Jev configured, three text reads cost about $0.0006. Each Sonnet call, a hold or a screenshot, adds $0.005–$0.02. For scale, one main Sonnet 5.5 generation of an SDK turn averaged $0.047 on prod over the last 30 days (measured: 224 `$ai_generation` events). Not run yet: how many judged reads a typical Dev turn makes. That needs an aggregate read of Dev's chat rows, which was refused in this session and waits on Reinier. Once shadow is on, the events answer it directly: count `chat_read_screen_shadow_decision` per `trace_id`, one reply with any subtasks it spawned. ### The first queries (HogQL) ```sql -- Hold rate by tool select properties.tool_name as tool, count() as reads, countIf(properties.verdict = 'hold') as held, round(held / reads, 3) as hold_rate from events where event = 'chat_read_screen_shadow_decision' and timestamp > now() - interval 7 day and properties.impersonated = false group by tool order by reads desc -- Holds by reason code and the stage that decided select properties.reason_code as reason, properties.judge as judge, count() as reads, countIf(properties.verdict = 'hold') as held from events where event = 'chat_read_screen_shadow_decision' and timestamp > now() - interval 7 day and properties.impersonated = false group by reason, judge order by held desc -- Reads held by source kind select properties.source_kind as source, count() as reads, countIf(properties.verdict = 'hold') as held, round(held / reads, 3) as hold_rate from events where event = 'chat_read_screen_shadow_decision' and timestamp > now() - interval 7 day and properties.impersonated = false group by source order by held desc ``` ### Changes 🏗️ - `backend/copilot/gate/shadow.py` (new): `judge_later`, per-chat sampling, the in-flight cap, the finding and the PostHog event. - `backend/copilot/gate/reads.py`: `screen_mode`, the shadow branch of `screen_read`, `_judge` shared by both modes, release following the new flag, source kinds. - `backend/copilot/gate/__init__.py`: `answerable()`, shared by `gate_active` and the read screen. - `backend/copilot/gate/content.py`: the deciding stage and a fixed code on each verdict. - `backend/util/feature_flag`: `COPILOT_READ_SCREEN` and `get_flag_variant`. - `backend/copilot/config.py`: `read_screen_shadow_sample_rate`. - `backend/util/posthog_events.py`, `docs/engineering/tracking-plan.md`: `chat_read_screen_shadow_decision`. - Three test fixtures that switched the read screen on through `copilot-auto-mode` now set `copilot-read-screen`. `gate/reads_test.py` sets the new flag alone, so the whole read suite runs with auto mode off. Verified: I ran the 20 tests in `gate/shadow_test.py`. I ran every test file that references the gate together with the flag layer's tests, `tree_test.py`, the chat routes and the PostHog event pins: 1,604 passed. And I ran `copilot/executor`, `copilot/sdk`, `copilot/baseline`, `copilot/tools`, `copilot/bot`, `copilot/gate`, `util/architecture_test.py` and `blocks/test/test_block.py` together: 9,777 passed, 0 failed. I broke each behaviour the new tests guard and watched its test go red: 16 mutations, all caught, including one for each value of the flag. Pyright and black are clean on the changed files. Not exercised: a live judge, a deployed environment, or a real PostHog event. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Each value of `copilot-read-screen` is one mode: true and `enforce` hold, `shadow` judges and records, false, missing and an unknown variant are off - [x] The read screen and the release of a held read follow the new flag, not `copilot-auto-mode`; an env override of off turns shadow off too - [x] Shadow hands every read over whole; one event per judgement with exactly the listed properties and none of the content - [x] A judge that hangs changes neither the result nor its timing; a judge that fails is recorded as `judge_error` - [x] Sampling and the in-flight cap #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
cd9e0806d0 |
fix(platform): assign experiment arms on the server, so a user keeps theirs without analytics consent (#15385)
### Why / What / How
**Why:** a signed-in user who declines analytics cookies gets a fresh
PostHog id on every page load and is never identified. The browser
evaluated the onboarding pricing experiment
(`subscription-pricing-page-initial-state`) on that id, so the same user
could see a different pricing arm on each visit.
Resolves SECRT-2740
**What:** the backend now assigns the arm. `GET
/api/experiments/assignments/{experiment}` returns the arm stored for
the user. If none is stored, it buckets them by user id, stores the arm
write-once with `source="backend"`, and returns it; it returns `null`
while they are not enrolled. The onboarding pricing step reads the arm
from there instead of from posthog-js, so a user sees the same arm on
every page load, consent or not.
**How:**
- PostHog still does the bucketing. The backend reads the variant from
its local flag definitions with `only_evaluate_locally=True` and
`send_feature_flag_events=False`, so the read sends PostHog nothing
about the user.
- **Without `POSTHOG_SECRET_KEY` there are no local definitions, so no
new user is enrolled**: they see control, and users with a stored arm
keep it. Dev and prod carry the key since infra #422, which merged on
2026-10-10. A read that comes before the first definitions poll stores
nothing, and so does one whose user lookup failed, since an arm targeted
on email or role cannot be read without the user's properties. The next
read enrols the user.
- The browser reports the exposure itself: `$feature_flag_called` with
the server's arm, sent through `capturePostHogEvent`. That helper holds
the event until PostHog captures and drops it for visitors who decline,
so PostHog's experiment results count consenting users only, including
one whose arm loads before PostHog opts in. It is sent once per user per
tab, so a remount sends no second event.
- So the experiment is not in `EXPERIMENT_FLAGS`, the list of flags
whose server reads send that event (#15383): a server read of its key
would double the browser's event and send one for users who declined.
The list now holds only experiments read on the server alone, none
today.
- Experiments are declared once, as `Experiment` on the backend. The
path parameter accepts only those, and the frontend uses the generated
enum.
- `useReportAssignment` and `POST /assignments` are removed: the server
is now the only writer of `ExperimentAssignment`. A test default answers
"not enrolled", so other suites keep the control experience.
- The query is keyed by user id and does not run while signed out.
### Changes 🏗️
- Backend: `Experiment`, `experiments.assign`,
`evaluate_experiment_variant` (PostHog, local only, no exposure event,
no arm when the user lookup failed), and `GET
/api/experiments/assignments/{experiment}`. `POST
/api/experiments/assignments` is removed. `EXPERIMENT_FLAGS` no longer
lists the pricing experiment.
- Frontend: `useExperiment` reads the server's arm and reports the
exposure through `capturePostHogEvent`; `useReportAssignment` is
removed.
- Docs: the experiment steps in `docs/engineering/activation-metrics.md`
describe the server-assigned flow.
- Config: none. Enrolment needs `POSTHOG_SECRET_KEY` on the backend,
which infra #422 sets for Dev and prod.
### Verified
I ran the backend tests against a real posthog-python client holding the
experiment's definition, with no network. Reading the arm calls neither
`capture` nor the SDK's `/flags` request. With no local definitions
nobody is enrolled, again without a request and without waiting on the
SDK's own definitions fetch, and nobody is enrolled when the user lookup
fails. A stored arm beats PostHog's current bucket. A first read stores
PostHog's arm as `backend`, and stores nothing when the user is not
enrolled. The route refuses a flag that isn't a declared experiment. No
server read of a declared experiment, through `get_feature_flag_value`
or the arm read, sends `$feature_flag_called`. That is 11 passed, and
each of 8 mutations turned its own test red: exposure event on, remote
fallback allowed, stored arm ignored, wrong source, untyped path, the
pricing key back in `EXPERIMENT_FLAGS`, an arm read on a failed lookup,
a read before definitions load. Also green after merging dev, #15383
included: `backend/util/feature_flag/`, the flag endpoint's tests,
`product_analytics_test.py` and `architecture_test.py` (318 passed), and
`test_block.py` (3,696 passed). On the frontend, `useExperiment`'s 7
tests (served arm plus exposure, exposure held until PostHog captures,
one exposure across remounts, one per user, not enrolled, signed out,
per-user arm) caught each of 8 mutations. The 58 onboarding, paywall and
billing test files gave 683 passed, with the pricing tests now driven
through the endpoint. The full frontend unit suite passed 882 of 883
files. The one red, `stream-drift-reconnects.test.tsx` W1, is copilot
code this PR does not touch, and it passed 3 of 3 runs on its own.
pyright and `tsc` are clean. The frontend figures above predate the dev
merges. On the current head, the flag package, experiment, route,
paywall and architecture tests give 278 passed, `test_block.py` 3,696
passed, and the experiment, analytics and onboarding test files 581
passed; pyright and `tsc` are clean. Reasoned only: the experiment
against the live PostHog project.
### Agents and large language models used
- Claude Code with Claude Opus 5.5
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Backend assignment and variant-read tests against a real PostHog
client, with mutations
- [x] `useExperiment` tests against the generated MSW handler and the
Cookiebot stand-in, with mutations
- [x] Affected frontend suites and the full unit suite; flag, analytics,
architecture and block suites; pyright; `tsc`
#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
|
||
|
|
58985034f0 |
fix(backend): correct transactional email copy, amounts and mobile layout (#15552)
### Linear ticket Resolves SECRT-2943 ### Why / What / How Payment emails currently say paid access continues after a failed renewal, the final notice can use a past renewal date as a future pause date, and a $4.20 Briefing spend can appear as $0.04. Trial messages need clearer cancellation instructions and a proper paid welcome. Phone content is too narrow when clients remove the style block. This fixes those messages in HTML and plain text, keeps dollar amounts in the app's unit, and moves responsive gutters inline. Payment failure outcomes gate both Stripe failure-event paths, so wallet-settled, stale, replaced and processing invoices do not generate failure emails. Final notices require the subscription's current `unpaid` state, use the event date, and offer the hosted invoice when available; a missing retry timestamp alone does not imply exhaustion. Older queued payloads remain readable. The added data-model fields are display-only; the payment handler retains its existing customer/subscription ownership checks, covered by the Stripe/wallet regression tests. Trial conversion uses its actual payment date and retains the legacy-notice compatibility check. **Stack:** depends on #15345. The temporary integration base combines its head `afcef5928` with dev and a historical test-fixture compatibility adjustment. Bently's branch is unchanged. This PR's diff contains only the email follow-up and its evidence. **Merge the latest `dev` and retarget after #15345 merges; do not merge into the temporary integration branch.** ### Changes 🏗️ - Truthful payment-failure/final-notice copy and notification eligibility, with optional retry and last-attempt fields for safe migration. - Dollar amounts in the Briefing, balance forecasts and out-of-credit alerts; no second conversion. Remove the zero-failure preheader clause and use a neutral billing greeting fallback. - Trial limit and cancellation instructions, an appropriate expired-trial action, and a converted-trial welcome with useful starting links. - Inline 22px card gutters, a centered 464px content column and Outlook wrapper, a full-width attention strip, and fonts loaded outside the style block. - Budget wording for the dormant guardrail cause, plus shared-layout and stale signature/jargon fixes for the marketing reference templates. These references do not send marketing campaigns. - Preserve the existing image assets. The public Otto URL now returns a PNG, and its bytes match the checked-in export; no duplicate asset is needed. - Regression tests and deployment ordering notes. This PR does not enable notifications or change production billing settings. Real inbox checks and release enablement remain operator steps. ### Validation - 343 focused notification tests passed locally, including rendering, billing/trial copy, queued-payload compatibility and freshness checks. - 71 failed-payment/wallet tests passed, including the new notice-outcome cases; the existing billing recovery behavior is preserved. - Changed Python files: pyright reports 0 errors/warnings. Formatting, lint and secret checks passed. - Tests were written and observed failing before the copy/layout and payment-state fixes. - Service-backed webhook/delivery checks run in CI; the local environment has no integration stack. CI status is reported by the checks on this PR (including the manually dispatched backend workflow for this stacked base). ### Actual before / after renders These are full-page Chromium screenshots of the real templates and synthetic data, **before on the left and after on the right**. No AI-generated mockups, customer messages, private review documents or production credentials. Email pixels are unscaled; only labels and a separator were added. 52 variants were checked in 728 browser cases: 640/390/360px, styles removed at 390/360px, images blocked, and a dark-color preference. After cases have no horizontal overflow or unexpected broken images; a 390px screen retains a 346px content column without style blocks. This is browser validation, not a claim of Gmail/Outlook inbox certification. Image requests use the exact checked-in PNG bytes, verified against the live public images. The Briefing money sample runs the real builder with fake RPC data, and balance alerts use the real formatter, so the before render exposes the production double-conversion/unit problems. New date fields in the after fixtures match the producer changes. All plan prices and links are illustrative test values. [Source commits, measurements and full render index](https://github.com/Significant-Gravitas/AutoGPT/blob/ce91ef30e/docs/engineering/email-review/README.md). <details> <summary>payment_failed: 390px before / after</summary>  </details> <details> <summary>payment_final_notice: 390px before / after</summary>  </details> <details> <summary>briefing-weekly: 390px before / after</summary>  </details> <details> <summary>cause-low-balance: 390px before / after</summary>  </details> <details> <summary>cause-zero-balance: 390px before / after</summary>  </details> <details> <summary>trial-started: 390px before / after</summary>  </details> <details> <summary>trial-converted: 390px before / after</summary>  </details> <details> <summary>trial-ended: 390px before / after</summary>  </details> <details> <summary>All other changed variants: complete 390px comparisons</summary> **alert-0**  **alert-3**  **auth-change_email**  **auth-reset_password**  **auth-set_password**  **auth-verify_email**  **briefing-attention-overflow**  **briefing-daily**  **briefing-monthly**  **briefing-quiet**  **briefing-rough**  **cause-auth-expired**  **cause-awaiting-input**  **cause-awaiting-review**  **cause-block-failed**  **cause-continuous-error**  **cause-guardrail**  **cause-low-balance-no-forecast**  **cause-paused-failures**  **ended-payment-False**  **ended-payment-True**  **ops-processed**  **ops-request**  **reference-changelog**  **reference-tour-1**  **reference-tour-2**  **reference-tour-3**  **reference-tour-4**  **reference-tour-5**  **reference-tour-6**  **subscription_cancelled**  **subscription_resumed**  **subscription_welcome**  **trial-canceled**  **trial-ending**  **trial-payment_failed**  **trial-resumed**  **verdict-approved**  **verdict-changes**  **verdict-no-feedback**  **welcome-experts-enabled**  </details> <details> <summary>Desktop and style-stripped comparisons (12 representative variants)</summary> **auth-verify_email-390-style-off**  **auth-verify_email-640-normal**  **briefing-weekly-390-style-off**  **briefing-weekly-640-normal**  **cause-low-balance-390-style-off**  **cause-low-balance-640-normal**  **cause-zero-balance-390-style-off**  **cause-zero-balance-640-normal**  **ops-request-390-style-off**  **ops-request-640-normal**  **payment_failed-390-style-off**  **payment_failed-640-normal**  **payment_final_notice-390-style-off**  **payment_final_notice-640-normal**  **reference-changelog-390-style-off**  **reference-changelog-640-normal**  **reference-tour-1-390-style-off**  **reference-tour-1-640-normal**  **subscription_welcome-390-style-off**  **subscription_welcome-640-normal**  **trial-converted-390-style-off**  **trial-converted-640-normal**  **trial-started-390-style-off**  **trial-started-640-normal**  </details> <details> <summary>greeting-fallback: actual conditional lifecycle payload before / after</summary>  </details> <details> <summary>payment-no-retry-date: actual conditional lifecycle payload before / after</summary>  </details> <details> <summary>final-hosted-invoice: actual conditional lifecycle payload before / after</summary>  </details> ### Agents and large language models used - Codex (model version unavailable). ### Checklist 📋 - [x] Changes and stack dependency described. - [x] Meaningful regression tests and local validation recorded. - [x] All changed email variants have public-safe before/after renders. - [x] No secrets or private review material included. - [x] CI completes successfully on final head `5910ddb`: [backend matrix](https://github.com/Significant-Gravitas/AutoGPT/actions/runs/38765042764), [fullstack](https://github.com/Significant-Gravitas/AutoGPT/actions/runs/38764984708), and Codecov checks passed. - [ ] Before release: validate real inbox delivery and effective production settings. Co-authored-by: Bentlybro <[email protected]> Co-authored-by: unknown (Codex) <[email protected]> Co-authored-by: Toran Bruce Richards <[email protected]> Co-authored-by: Claude Opus 5.5 (unknown) <[email protected]> Co-authored-by: gpt-6-astra (Codex) <[email protected]> |
||
|
|
9c77fa1b67 |
fix(frontend): keep the website visitor's PostHog id when they sign up (#15940)
Part of [SECRT-2769](https://linear.app/autogpt/issue/SECRT-2769). Goes with the marketing site's [#65](https://github.com/Significant-Gravitas/autogpt-marketing-site/pull/65) (Cookiebot) and [#66](https://github.com/Significant-Gravitas/autogpt-marketing-site/pull/66) (PostHog). ### Why / What / How **Why:** GTM wants one funnel from a website visit to a paid account. The site now sends to our PostHog project and leaves its PostHog id in a cookie on `.agpt.co`, which the app is meant to adopt at signup. Today the app throws that id away, so the website pageviews land on a separate person and the funnel breaks at signup. **What was broken:** Cookiebot answers per domain, so a visitor who accepted on agpt.co sees the banner again on platform.agpt.co. While it was open, the app: 1. deleted PostHog's storage, the shared `.agpt.co` cookie included (`forgetPostHogStorageWithoutConsent`); 2. started PostHog on a fresh in-memory id; 3. on accept, saved that fresh id and overwrote the shared cookie with it. The same happened in implied-consent regions, where uc.js grants only after PostHog has started. **How:** - **Nothing is deleted while the answer is pending.** PostHog storage, the `.agpt.co` cookie and `agpt_anonymous_id` are only deleted after an answer without analytics (or without a Cookiebot domain group, as before). While pending, PostHog is left as it is and a `before_send` hook (`dropWithoutConsent`) drops its events. In posthog-js 1.334.1 every way to opt out or stop deletes the stored state, so this is the same approach as the site. Only PostHog's own opt-in flag is cleared before init, so it still starts opted out. - **On a grant, the app picks the visitor's id in this order:** the app's own `agpt_anonymous_id`, so returning app visitors keep theirs; then the cookie's `$device_id` (the website's); then the page's in-memory id. This applies to a grant during the page and to a stored grant at load. - **No reload.** If PostHog started on the in-memory id, it moves onto the adopted id before its storage becomes persistent: `register({ distinct_id, $device_id })`, the call posthog-js makes for `bootstrap`. Feature flags are then reloaded for the new id. Nothing was captured under the in-memory id, since PostHog was opted out, so there is nothing to merge. I chose this over reloading the page once, which would flash the page and lose what the visitor had typed (they're often on signup), and over starting PostHog only after the answer, which would leave feature flags unevaluated for anyone who never answers. - **Only a new visitor or a new account takes the website's id.** The cookie's `$device_id` is adopted only when nobody is signed in as the answer arrives, or the account was created in this browser (the `agpt_account_created` flag). That way an older account signed in on a shared browser doesn't take over someone else's website visits through `identify()`. The auth store tells `anonymous-id.ts` whether someone is signed in. - **The website-id choice waits for the session.** Until the auth store knows whether someone is signed in (after its fallback validation), PostHog stays opted out and in memory, page views are held in the capture queue, and nothing is stored. Once it knows, the id settles and PostHog moves onto it and starts, before `identify()`. Both device ids are read once, before PostHog rewrites them. The signup flag is remembered while the user is signed in, because the ads conversion clears it. - **If Cookiebot asks again after an earlier grant,** PostHog stops capturing, so nothing is written while the answer is pending. Its storage, including the shared cookie, stays: `opt_out_persistence_by_default` is turned off first, because otherwise posthog-js deletes its storage when capture stops. A grant resumes it on the same id. - **The backend gets the same id as PostHog.** The attribution report waits while the answer is pending, then sends the adopted id on a grant, or the in-memory id on a decline. It is sent at once when there is no Cookiebot domain group, or when uc.js still hasn't arrived 10 seconds after the page loaded. A visitor who never answers is reported on a later page where they do. - **Logout without analytics consent deletes PostHog's storage, the shared cookie included.** In memory mode `reset()` would leave the previous person's id behind for the next account. - **"Cookie settings" link:** `subscribeToConsentManagerStatus` also listens to Cookiebot's dialog events (as the site does). uc.js is injected after hydration and can arrive after the page has finished loading. In that case a visitor with no answer gets no consent event, so the link stayed hidden. ### Changes 🏗️ - `services/consent/consent.ts`: new `hasDeclinedConsentFor(category)`; the manager status also follows `CookiebotOnDialogInit`/`CookiebotOnDialogDisplay`. - `services/analytics/anonymous-id.ts`: no deletion while pending; on a grant, adopt the stored id, else the shared cookie's `$device_id`, else this page's id. - `providers/posthog/posthog-consent.ts` and `posthog-provider.tsx`: delete storage only on a declined answer, leave PostHog alone while pending, `before_send: [dropWithoutConsent, markImpersonatedEvent]`, and move PostHog onto the adopted id on a grant. - `docs/engineering/tracking-plan.md`: a new "Marketing site" section (`source: website`, `$pageview`, `section_viewed` with `section`, and the site's DataFast goal → PostHog event names such as `get_started_clicked`), plus the hand-over rule. `website` is added to the `source` base property. **User-visible:** the "Cookie settings" link now appears for a new visitor once the Cookiebot banner loads, even if it loads after the page. **Analytics:** a visitor who accepts on both sites now has the website pageviews on the same person as their signup. A visitor who doesn't answer the app's banner keeps their stored PostHog state (nothing is read or sent until they answer). LaunchDarkly keeps the page's in-memory id until the next page load. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Real posthog-js, page on `platform.agpt.co` with the website's cookie on `.agpt.co` (`posthog-consent.test.tsx`): - [x] the cookie is kept while the answer is pending; - [x] declining deletes it; - [x] accepting moves PostHog, `agpt_anonymous_id` and the cookie onto the website's id, and flags are re-read for it; - [x] the same when uc.js grants only after PostHog has started (implied consent); - [x] a stored grant starts on the website's id; - [x] a returning visitor keeps their own `agpt_anonymous_id`; - [x] Cookiebot asking again after a grant stops writing and keeps the `.agpt.co` cookie, and a decline then deletes it; - [x] a new account's `$identify` carries `$anon_distinct_id: website-device`, and the attribution payload carries the website id; - [x] an older account signed in at grant time doesn't take the website id; - [x] logout while pending deletes the shared cookie. - [x] Attribution waits for the answer, and is sent at once with no CBID or a blocked uc.js (`useReportAttribution.test.tsx`); the auth store wiring for the signed-in rule (`auth-identity.test.ts`). - [x] The new hand-over tests fail on `dev` without the fix. - [x] `anonymous-id.test.ts`: pending keeps the stored id and landing; a grant adopts the cookie's device id. - [x] Login page: the "Cookie settings" link appears once Cookiebot asks a visitor with no answer, after the page finished loading (fails without the fix). - [x] `pnpm lint`, `pnpm types` and the analytics, consent and PostHog test suites pass. - [ ] After the site's #66 is live: on a fresh browser, accept on agpt.co, then sign up on platform.agpt.co and check that the PostHog person has the website pageviews. #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
47f9437827 |
feat(frontend): correlate Sentry errors with PostHog (#15690)
> **Taken over by Krzysztof (with Claude Code).** Nick handed this PR over; the bridge, tests and runbook below are his work, updated where the takeover changed them (marked *Takeover*). ### Why / What / How Browser errors in Sentry need a direct connection to the affected person's activity in PostHog. This mirrors eligible error/fatal events through PostHog's Sentry SDK bridge, preserving the exact Sentry event ID and adding person/replay links back onto the Sentry event. The bridge runs at the end of Sentry `beforeSend`, after the existing Sentry filters, and checks analytics consent and SDK opt-out for every event. PostHog failures preserve the original Sentry event. Native PostHog exception autocapture is disabled so the bridge is the single browser exception capture path. Disabling Sentry also stops PostHog exception mirroring; product analytics remains independent. The SDKs deliver independently: rate limits, quota rejection, or network failures can leave a PostHog exception without a stored Sentry event, so event counts and billed usage can differ. **Takeover: PII ordering.** Since #15705, `beforeSend` strips every event first (user reduced to `{id, role}`, no email or IP address, IP-like span attributes dropped). The mirror runs last, so PostHog's `$exception` is built from the stripped event. I read posthog-js 1.334.1's `sentryIntegration().processEvent`: it copies only the exception (type, message, mechanism, stack frames), `event.message`, the level, the event ID and the Sentry tags. It never copies the Sentry user, request (URL, headers), contexts, extra data or breadcrumbs. A real-SDK test now proves this end to end. PostHog then adds the standard properties it puts on every consented event (current URL, browser, device, session, person). **Takeover: variable rename.** The backend already uses `POSTHOG_API_KEY` for the public project key, so the build-only source-map upload credentials are renamed to `POSTHOG_SOURCEMAP_UPLOAD_KEY` and `POSTHOG_SOURCEMAP_PROJECT_ID`. They are never `NEXT_PUBLIC_` and never in `next.config.mjs` `env`, so they stay out of the browser bundle. ### Changes 🏗️ - Add Sentry event links in PostHog and PostHog person/timestamped replay tags in Sentry, including anonymous visitors and preview Sentry projects. - Cover consent withdrawal, denied consent, opt-out, warning exclusion, filtered errors, and PostHog failures using the real PostHog integration. - Keep PostHog rate-limit console diagnostics in Sentry without mirroring them back into PostHog. Regression tests exercise the real rate limiter and preserve application console errors. - Carry forward the wizard's optional source-map uploader with `@posthog/nextjs-config`. Enable it only for production builds with source maps and both upload credentials; retain maps for both uploaders. - Add blank `POSTHOG_SOURCEMAP_UPLOAD_KEY` and `POSTHOG_SOURCEMAP_PROJECT_ID` defaults and a Docker BuildKit secret mount. Existing Compose development builds remain compatible without upload credentials. - Document deployment, what PostHog receives, usage implications, and preview verification in `docs/engineering/sentry-posthog-correlation.md`. This covers future browser events; backend correlation, historical backfill, and issue-status synchronization are outside this change. - Wait for chat transports to load in the journey helper before sending the first message, so reconnect scenarios start with an available AI connection. - *Takeover:* merge `dev` (#15705's PII stripping) and keep the mirror after it; add `sentry-posthog-instrumentation.test.ts` (renamed from `sentry-posthog-console.test.ts`) case that sets an email, IP address, IP headers, trace data and a breadcrumb URL carrying a token, and checks none reach the captured or delivered PostHog event, and that `$exception` has exactly the integration's fields. - *Takeover:* rename the upload variables (next.config.mjs, Dockerfile secret mount, `.env.default`, runbook); new `next-config-posthog-sourcemaps.test.ts` checks the upload runs only with both new variables, ignores the backend's `POSTHOG_API_KEY`/`POSTHOG_PROJECT_ID`, and keeps the key out of the browser-inlined `env`. - *Takeover:* failed HTTP responses the HttpClient integration reports (mechanism `auto.http.client.*`) are skipped on purpose, not only because they carry no level, so a failing PostHog relay can't be mirrored back into PostHog. - *Takeover:* the bridge takes the Sentry organization from `HOSTED_SENTRY_DSN` instead of its own literal (the review bot's repeated suggestion), with a regionless-DSN test case. ### Deployment steps - **Vercel, Production environment (build), optional:** add `POSTHOG_SOURCEMAP_UPLOAD_KEY` (a PostHog personal API key with source-map write access, stored as a sensitive variable) and `POSTHOG_SOURCEMAP_PROJECT_ID` (`117338`). Without them builds pass as before and stack traces in PostHog stay minified. Do not reuse the public `POSTHOG_API_KEY`/`NEXT_PUBLIC_POSTHOG_KEY`. - No runtime configuration: the bridge uses the existing public PostHog key/host, Sentry DSN and Cookiebot setup, and applies to future errors once deployed. - After deploy, follow the runbook's preview checks (upload, source resolution, links both ways). ### Agents and large language models used - Codex through T3 Code with gpt-6-astra (Nick's original implementation). - Claude Code with Claude Opus 5.5 (takeover: dev merge, PII ordering test, variable rename, org constant, PR updates). ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan: - [x] `pnpm format`, `pnpm lint`, `pnpm types`, and all applicable pre-commit hooks. - [x] The documentation-only delivery clarification in `4e3d786500` passes all applicable pre-commit checks and eight documentation-layout tests. - [x] 37 focused correlation/provider/instrumentation/HTTP-filter tests pass on `9fe0b4f86d`, including five console rate-limit regression cases. The bridge has 100% line and branch coverage. - [x] A controlled headless browser probe reproduced an early journey submit before chat transports loaded; the updated helper waits for readiness and sends exactly once. - [x] Four config smoke checks: missing credentials, missing project, maps disabled, and both uploaders configured; private credentials are absent from the browser `env` config. - [x] PostHog source-map CLI startup (`posthog-cli 0.16.2`). - [x] 102 tests pass in a UTC rerun covering the changed areas plus authentication and usage-limit tests; six performance-fixture tests pass with the tracked LF file bytes. - [x] Run the full suite on the initial head `277a17a`: `pnpm test:unit --maxWorkers=4` completed 974 files with 10,666 tests passed, four expected failures, and four local failures. See the diagnosis below. - [x] *Takeover:* 209 tests across the bridge, instrumentation, next-config, PostHog provider and environment suites pass after the `dev` merge, including the new PII and source-map-variable tests. - [ ] Verify actual upload, source resolution, and links in a deployed preview using the documented runbook. Full-suite diagnosis (Nick, initial head): `RateLimitResetDialog` and `UsageLimitReachedCard` expect Oct 12 UTC but render Oct 11 in this Windows host's Chicago timezone. Both also fail on clean base commit `a8ca21ad9d`, and pass in the UTC rerun. The performance fixture byte comparison also fails on the clean base due to checkout CRLF; all six tests pass after restoring the fixture's exact tracked LF bytes (no Git diff). The email-verification test timed out in the broad run; all 48 tests pass in isolation on both base and this branch, and also in the 102-test UTC rerun. No unrelated application or test code was changed to address these host-dependent results. #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: gpt-6-astra (Codex) <[email protected]> Co-authored-by: Krzysztof Czerwinski <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
939e2c3124 |
feat(frontend/onboarding): hire the whole recommended team in one click on the hire step (#15836)
The onboarding wizard's hire step gets a primary button that hires every recommended expert in one click, a tick on each card to leave someone out first, and "I'll add members later" moved down to a grey text action under that button. Resolves [SECRT-2834](https://linear.app/autogpt/issue/SECRT-2834) ### Why / What / How **Why:** After the brain dump, a new user reaches the hire step: Otto proposes a few experts for their work, and each card has its own small "Hire" button. Until they hire someone, the only full-width button on the screen reads "I'll add members later", so the step's most prominent action is the skip. Taking the whole proposed team takes one click per card, and there is no way to take most of it minus one. More than half of the trial users who started in the last three weeks have not hired a single expert. **What:** - The primary button hires every expert still unhired. It reads "Hire all 3" at first, or "Hire Maria" when there is only one. Once some are hired by hand it reads "Hire the other 2" or "Hire Max too", and with everyone hired, or nobody recommended, it reads "Continue". - Each unhired card has a tick in its corner, on by default. Clicking it leaves that expert out, and the primary counts who is left in: "Hire all 3", then "Hire 2", then "Hire Maria". With everyone left out, the grey line is the only action. - The grey line under the primary moves on without hiring more. It reads "I'll add members later" with nobody hired and "Continue" after that. - The hires run in parallel, and each card flips to Hired as its own request lands. The primary reads "Hiring…", and the grey line and the wizard's Back button are hidden until the batch ends. When every hire lands, the step moves on by itself half a second later. - If a hire fails, the experts that were hired stay hired, and the failed card keeps its Hire button. One toast names who couldn't be hired ("Couldn't hire Sam", or "Couldn't hire your team" when every hire failed), and the primary reads "Try again". A failure never moves the step on. - The per-card Hire buttons work as before. - Analytics: a new `hire_all_clicked` event (`count`, `already_hired`, `recommended`, `retry`); a `via` property on `hire_step_continued` (`auto`, `continue`, `later`); and one `hire_started` per expert in a batch. The tracking plan and the event-name pin test have the new row. **How:** `hireStepActions` in `helpers.ts` is one pure function from the recommended experts, the wizard store's hired ids, the last batch's failures and the left-out ids to the primary's label, its batch and the grey line, so each label in the design's copy table comes from one place. The new `useHireAll` hook runs the batch with `Promise.allSettled`, one `POST /api/experts` per expert because the endpoint takes a single template. It marks each hire in the wizard store as it lands, invalidates the roster once, then either schedules the advance or records the failures. It sets the store's `isStepBusy` from the click until the step moves on, which hides Back, as the brain-dump step already does. Back during the half-second beat would otherwise land the scheduled `nextStep` on the previous step. Browser navigation can still unmount the step mid-batch, so a batch whose step is gone records its hires and stops there. It schedules no advance and shows no toast, and unmounting clears an advance that was already scheduled. `hire_step_continued.hired` is read from the store when the event is sent, because the auto-advance runs in a closure made before the batch's hires landed. The left-out ids are local to the step, so going Back and forward ticks every card again. The build follows the onboarding design for this step. It was not checked against the two PRDs that own the screen; the open choices are in a comment below. ### Changes 🏗️ - `HireStep/helpers.ts`: `hireStepActions` and `hireFailureToast` replace `continueLabel`. - `HireStep/useHireAll.ts` (new): the batch, its busy state, the advance, the failure toast, and the stop for a batch whose step was left. - `HireStep/useHireStep.ts`: wires the batch, the left-out ids and `via` into the step. - `HireStep/HireStep.tsx`: renders the new actions in place of the old Continue button, and gives each card its tick and toggle. - `HireStep/components/HireStepActions.tsx` (new): the primary, its pending skeleton and the grey line. - `HireStep/components/RecommendedExpertCard.tsx`: the include toggle, which reuses the wizard's `SelectableCard` tick badge. - `services/analytics/posthog-events.ts`, its pin test and `docs/engineering/tracking-plan.md`: `hire_all_clicked`, `via`, and the `hire_started` wording. - `HireStep/__tests__/HireStep.test.tsx`: nine new tests, and four existing tests updated to the new labels. Two of the new tests are parametrized: one over five label states, one over when the step is left mid-batch. Verified: I ran the hire step's tests through `HireStep` with the real hooks and MSW handlers, and all 29 passed in each of three runs. Each new test was proved by a mutation. I applied 22 mutations to the hire step's code, among them a batch that ignores the left-out ticks, a failure that still advances, a skip left visible mid-batch, a step not held busy through the advance beat, and a batch that still advances after its step was left. Every one turned at least one test red (table in a comment). I also ran all 53 test files under `onboarding/`, `services/analytics` and `services/onboarding` (618 passed), plus `pnpm types`, eslint and prettier. The screenshots render `HireStep` in Storybook from the payload the backend's own `fallback_expert_recommendations` builds from the local roster. The real recommendations endpoint, the paywall step after this one and mobile widths were not exercised. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] "Hire all 3" hires all three, the cards flip, and the step moves on with `via: auto` - [x] A left-out expert is not hired, and the label counts who is left in, down to the grey line alone - [x] One of three failing keeps the two hires, names the failure, offers "Try again" and does not move on - [x] The skip stays reachable and hires nobody; the skip is hidden and the step stays busy (which hides Back) from the click until the step moves on - [x] A double click starts one batch and moves on once - [x] A batch whose step is left mid-flight, before or after its hires land, never moves the wizard on 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
83fb2b08ad |
feat(platform): keep the suggested first tasks on Home until each is hidden (#15837)
After onboarding, Home keeps Otto's suggested first tasks as a short list under the composer: clicking one starts its chat and marks it in progress, and each stays until the user hides it. Resolves [SECRT-2835](https://linear.app/autogpt/issue/SECRT-2835) ### Why / What / How **Why:** a new user finishes onboarding and lands on Home, where Otto greets them and lists five or six first tasks built from what they said about their work. They click one, which opens a chat with it as the first message, and when they come back to Home the rest of the list is gone for good, because the list retired with the greeting on the first message. One misclick deleted the plan they had just been given. **What:** the greeting still shows once, but the suggestions now outlive it. After the first message they sit under the Home composer as "Suggested first tasks". A suggestion the user sent shows as "In progress" and links to its chat. Every row has a hide button, the block has "Hide all", and an Undo toast follows either. A reload, a new tab or another device shows the same list. The block goes away once every row is hidden, and it is not shown while an expert is the chosen recipient, because these prompts are Otto's. **How:** - Each row's state lives inside the existing `OnboardingBrainDump.suggestedPrompts` JSON: `id`, `state` (`open`, `sent`, `dismissed`), `session_id` and `updated_at`. A row written before this reads as `open`, with its list index as `id`, so there is no migration. - `GET /api/onboarding/brain-dump/suggested-tasks` reads the list without `/intro`'s one-shot semantics. `POST` with `{task_ids, state, session_id?}` changes rows, so "Hide all" and its Undo are one write each. Each write is a compare-and-set on the row's `updatedAt`, retried up to three times, so two tabs cannot overwrite each other. A sent row never reopens: undoing its hide brings it back as sent. - A user with no list of their own (the brain dump was skipped, or generation failed) still gets the generic starters on the greeting page. They are stored, and so shown on Home afterwards, only once the user clicks or hides one (`stored: false` until then), which keeps the generic set off the Home page of existing accounts. - A suggestion click goes through a new `onStartTask`, the page's send path returning the chat it started. The row is marked sent with that `session_id` only once the chat exists, and a failed or refused send leaves it open. The shared `onSend` keeps its `Promise<void>` type. - The list is read only once the greeting has landed: the pipeline writes the greeting and the list together, and an earlier read would cache the generic set. - The greeting card and the Home block render one `SuggestedTaskList`. Writes update the cache optimistically and run in one React Query mutation scope, so they reach the server in the order they were made. A failed write is retried up to three times before the row falls back, so a transient error never reopens a sent row and invites a second chat. - New events: `home_suggestion_clicked {position, surface, remaining}` and `home_suggestion_dismissed {position, surface, remaining, scope}`, added to the tracking plan. Both endpoints stay open while an account is paywalled (listed in `OPEN_WHILE_PAYWALLED`): they read and change only the account's own onboarding row, and the chat a sent row records was created through the paywall-checked chat route. ### Changes 🏗️ - Backend: `onboarding_dump/suggested_tasks.py` (new: read, state changes, rehydration of older rows), `db.py` (`ensure_dump`, `replace_suggested_prompts`), `models.py`, `routes.py`. - Frontend: `copilot/components/SuggestedTasks/` (the list, the Home block, the hook, helpers). `OnboardingIntroCard` renders the shared list, `EmptySession` mounts the hook and the block, and `useCopilotPage`/`useSendMessage` return the started chat's id through `onStartTask`. `/intro`'s `prompts` field is no longer read by the frontend; the endpoint is unchanged. - Analytics: both events in `posthog-events.ts`, the pin test and `docs/engineering/tracking-plan.md`. - `paywall_routes_test.py`: both routes listed as open while paywalled. ### Verified I ran the brain-dump package (325 passed), with 17 new route tests and 3 against Postgres for the compare-and-set write and the stub row. I also ran `architecture_test.py` (3 passed), `test_block.py` (3,696 passed, 84 skipped), pyright on the changed backend files, and the whole frontend unit suite (11,048 passed, 4 expected failures), with `pnpm lint` and `pnpm types` clean. Eight new page-level tests render `EmptySession` against MSW. Each of nine frontend mutations turned one of them red, and nine backend mutations did the same for theirs. The screenshots come from Storybook, seeded with payloads the backend code built. Nobody clicked through it on a running stack. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] A clicked suggestion sends its full prompt and is recorded as sent with its chat's id - [x] A send that starts no chat leaves the row open - [x] After the first message, the rest of the list shows under the composer with the started one in progress - [x] Hide, Undo, "Hide all" and its Undo, each as one write - [x] A hidden row stays hidden after a reload - [x] No block for a user without a list of their own, and none while an expert is the recipient 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
37899b0645 |
refactor(platform): retire mature product flags (#15799)
### Why / What / How Retires nine mature product rollout flags so files, experts, skills, onboarding, memory and community maintenance, spend approvals, and execution summaries are available independently of flag-provider configuration. Removes the obsolete variants and disabled branches while preserving authentication, ownership, user preferences, payment configuration, and the independent dream, skill-learning, voice, and payment controls. Supersedes #15766. Sidebar and chat-search retirement already landed in #15106 and is included in the `dev` base. ### Changes 🏗️ - Retire `artifacts-page`, `hire-experts`, `skills-hub`, `onboarding-brain-dump`, `onboarding-expert-team`, `graphiti-memory`, `graphiti-communities-enabled`, `expert-spend-approval`, and `ai-agent-execution-summary`. - Remove the old onboarding and marketplace variants; update wizard handoff, intro/dialog coordination, browser-test helpers, stale rollout comments, and dead test mocks. - Preserve memory scope/lifecycle safeguards. Register community maintenance without a provider decision while retaining optional-job intent when dream/learning evaluation is unavailable. `ENABLE_CREDIT=false` bypasses spend approval. - Read skill-write configuration once per batch now that hiring always installs bundled skills. Authorization, current quota, scanning, and publication checks still run per package; a later batch reads fresh configuration. - Cover setup-turn replies with the real chat SDK: a pending-message 409 waits for the existing stream to settle, including a delayed resume handshake. This adds regression coverage; it does not claim to fix the reported live race. - Regenerate OpenAPI, remove retired environment/CI overrides, document memory service/model requirements, and retain the Tuesday audit prompt. Provider definitions remain for older releases. No migrations or dependency changes relative to `dev`. ### Agents and large language models used Codex with gpt-6-astra, including implementation and independent review subagents. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan: - [x] Current merge `3992fc46b5` includes `dev` at `379862d327` (#15850 Postmark/PostHog analytics and #15851 in-app DataFast removal). The sole conflict was the onboarding tracking test: it now preserves PostHog assertions with the simplified layout API. All other incoming code remains intact; user lookup and webhook attribution/authentication checks are preserved. - [x] Current merge validation: 203 backend tests and 186 frontend tests across 20 files passed. Full frontend types, scoped Prettier/ESLint/oxlint, and 15-file backend Black/isort/Pyright passed. Backend Ruff findings are in files identical to dev. The Windows `pnpm format` script still fails on `--fix;`; repository-wide frontend lint reaches its existing Prettier/CRLF failures. No broad formatting, dependency installation, live-provider or shared-service changes were made. - [x] Helper follow-up `59f1b1ec27` guards intro-dialog button lookup when login navigation replaces its document. The prior smoke run passed 30 tests and failed the settings test on Playwright's cross-document adoption error in this helper. Two lookup regressions failed before the fix; all 14 onboarding/signup helper tests now pass, along with full frontend types and scoped Prettier/ESLint/oxlint. Unexpected errors still propagate and acquired handles are released. This is a test-helper fix; no application behavior changed. Fresh browser CI remains pending. - [x] Dev merge `6392cc1282` includes `dev` at `3fcf47bb2e` and security fix #15864: certifi 2026.10.19, Pillow 13.0.0, the pinned MoviePy revision, and Python >=3.11. Dependency files and image-scan wiring match dev. - [x] Validation of dev merge `6392cc1282`: 433 focused backend tests passed, 2 skipped; 264 frontend tests passed across 18 files. Full frontend types and scoped Prettier/ESLint/oxlint passed. All 17 changed Python files passed Black/isort/Pyright; Ruff introduced no findings. Poetry lock validation passed. - [ ] Local validation limits: cloud storage had 39 passes and 15 Windows path-normalization failures in source/tests identical to dev; full billing/subscription groups stopped on Redis retries. Local Python tests used the existing private dependencies, so fresh CI validates the upgraded image stack. No shared services or dependency installations were used. - [x] Earlier merge `fd88aaf2d6` includes `dev` at `a689834cd3`, including the related PostHog cache fix #15765 and Sentry URL redaction. Backend integration: 326 passed, 2 skipped. Frontend: 136 passed in the seven-file run; one first-import timeout passed on an isolated 28-test file rerun. Full TypeScript and scoped Prettier/ESLint/oxlint pass. No additional runtime edits were needed for the merge. - [x] Earlier follow-up head `4426611a57` included `dev` at `6c07c09d4c`; its validation follows below. - [x] Backend flag/memory-tool regressions: 261 passed, 2 skipped. Warm-context/retry regressions: 47 passed. - [x] Skill/workspace/storage regressions: 213 passed; six Windows path/chmod failures reproduced with unchanged implementation and excluded. The new real-write-path regression fails before the fix (six configuration loads for three skills) and passes afterward (one load). Quota changes during a batch and configuration changes between batches remain effective. - [x] Frontend follow-ups: 114 passed across 12 files; full TypeScript check passes. Focused Prettier, ESLint, and oxlint pass. Whole-tree formatting still reports existing CRLF noise; no broad formatting changes included. - [x] New setup-turn tests fail when the existing settle wait is removed and reproduce the reported SDK `undefined.state` exception. Both pass with the guard intact; production chat code remains unchanged. The original live report remains unconfirmed. - [x] Changed backend formatting/type checks pass; changed-file Ruff comparison introduces no findings. No live providers or shared services used for these follow-ups. - [x] CI on `4426611a57` confirmed the exact roster/hire acceptance test passes: Python 3.11 56.164 s, 3.12 48.425 s, 3.13 27.535 s. These are not controlled comparisons with the reviewer's VM timings. - [x] Both complete-image smoke tests on `4426611a57` passed: ARM64 406.84 s and AMD64 612.84 s. All 14 Codecov checks passed with 99.21% overall patch coverage. - [ ] Fresh complete-image security validation on `3992fc46b5`. The inherited certifi/Pillow failures are addressed by merged #15864, whose AMD64 and ARM64 build/smoke/scan checks passed. This PR now includes that exact dependency update; its fresh checks are pending. - [ ] Fresh CI on `3992fc46b5`; live PostgreSQL/FalkorDB checks were not run locally for these follow-ups. On previous head `fd88aaf2d6`, test/coverage checks passed with 99.21% patch coverage; both image smoke tests passed, while security validation failed on the now-updated dependencies. <details> <summary>Earlier validation before this follow-up</summary> Memory/dream: 1,211 passed, 4 optional PostgreSQL skips, 102 integration exclusions, 1 existing xfail. Product-backend unit collection: 836 passed across the original run and fixture rerun; four additional real-SDK thread-billing cases passed with a fake provider. Hosted-journey configuration, flag-sync, and documentation: 15 passed. Frontend flag, welcome, marketplace, sidebar, and layout groups passed their focused reruns, including 244 tests after the earlier dev merge. These groups overlap and were run on earlier heads; the current-head checks are listed above. </details> #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) --------- Co-authored-by: gpt-6-astra (Codex) <[email protected]> |
||
|
|
97eb1d22c2 |
feat(platform): flag our team and admin impersonation in PostHog (#15852)
### Why / What / How **Why:** our own team's clicks and admins impersonating users skew every chart, funnel and experiment. With a flag on the person and on the events, PostHog can leave them out everywhere. Resolves [SECRT-2728](https://linear.app/autogpt/issue/SECRT-2728) (trimmed: most signup person properties are already set server-side by #15530, #15466 and SECRT-2852; this adds what was missing). **What:** - `is_internal` person property: `true` for an admin (Better Auth role `admin`) or an `@agpt.co` email, else `false`. - Every PostHog event an admin causes while acting as a user is marked `impersonated: true`, from the backend and the browser. An admin acting as a user no longer changes the user's person properties. - The browser's PostHog session id goes to the backend, so server-side events join the same session. **How:** - `is_internal` is sent with the other account properties at signup (`posthog_audience.account_update`), again whenever an internal account signs in (`POST /api/auth/user`, so someone made an admin later is flagged from their next sign-in; nothing extra is sent for anyone else), and on earlier accounts by the existing `posthog-audience-backfill`. - New `AnalyticsContextMiddleware` reads `X-Act-As-User-Id` and `X-PostHog-Session-Id` into context vars. `posthog_client.capture` adds `impersonated: true` and `$session_id`, and drops `$set_person_properties` events while impersonated (onboarding role, checkout properties). The lifecycle sync still runs: it mirrors the stored trial and Stripe state, not the admin's action. The copilot turn's existing impersonation flag (`llm_attribution.set_turn_impersonated`, behind `metadata_impersonated`) now uses the same context var, so a turn an admin started marks all its events. - Browser: while impersonating, the browser is still identified as the admin, so its events land on the admin's person. A PostHog `before_send` hook adds `impersonated` and `impersonated_user_id`. It only touches events PostHog already captures, so nothing new is sent without consent. The session header is sent only with analytics consent and not while impersonating (`customMutator` through the proxy, and the chat's direct requests). ### Changes 🏗️ - Backend: `backend/api/middleware/analytics.py` (new), `util/posthog_client.py`, `notifications/posthog_audience.py`, `notifications/posthog_audience_backfill.py`, `cli/posthog_audience_backfill.py` (reads the role from `UserAuthIdentity` and reports the internal count), `data/user.py`, `api/features/user/routes.py`, `copilot/llm_attribution.py`. - Frontend: `services/analytics/posthog-request-context.ts` (new), the `before_send` hook in `posthog-provider.tsx`, the session header in `customMutator` and `getCopilotAuthHeaders`, and `x-posthog-session-id` added to the proxy's forwarded headers. - Docs: `docs/engineering/tracking-plan.md` gets a new section, "Our own team and impersonation", and `is_internal` is added to the person properties table. - No config or env changes. **For you to do after deploy:** 1. Backfill existing accounts (dry run first, then apply): `poetry run cli posthog-audience-backfill` then `poetry run cli posthog-audience-backfill --apply --yes` (needs `STRIPE_API_KEY` and `POSTHOG_API_KEY`. It resends the existing account and checkout properties with the same values, which is harmless.) 2. In PostHog, go to Project settings, Product analytics, **"Filter out internal and test users"** and add: - person property `is_internal` is not `true` - event property `impersonated` is not `true` Then turn on "Filter out internal and test users" by default for new insights (same page). **Follow-ups (not in this PR):** events the executor, scheduler or webhooks send later (for example `agent_run_finished` for a run an admin started) carry no `impersonated` flag or session. Someone who stops being an admin keeps `is_internal` until the backfill runs again. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Backend unit tests: `posthog_client_test` (impersonated mark, dropped `$set` while impersonated, `$session_id`), new `middleware/analytics_test`, `posthog_audience_test`, `posthog_audience_backfill_test`, CLI backfill test, `llm_attribution_test` - [x] `routes_test` (sign-in re-flag), `user_test`, and `posthog_audience_backfill_db_test` (role read from `UserAuthIdentity`) are updated; they need the DB, so CI runs them - [x] Frontend: new `posthog-request-context.test.ts`, provider, proxy, `customMutator` and copilot helper tests; `pnpm types` and `pnpm lint` pass - [ ] After deploy: impersonate a test user and check that the events in PostHog carry `impersonated: true` and the user's person properties don't change #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
379862d327 |
refactor(frontend): stop sending in-app events to DataFast (#15851)
### Why / What / How **Why:** One place for what people do in the app (PostHog), and one fewer copy of it. DataFast keeps doing what only it does: tying marketing-site visits to signups and purchases. Decided on 23 Sep ([SECRT-2718](https://linear.app/autogpt/issue/SECRT-2718)). **What:** The app stops sending DataFast its own events. It still loads the DataFast script, after consent, because attribution needs it. The script records the visit and sets the `datafast_visitor_id` / `datafast_session_id` cookies. The server-side `signup` goal, the `X-Datafast-*` headers and the Stripe Checkout metadata all read those cookies, and without them a visitor who lands straight on the app could not be tied to a channel. **How:** Removed every in-app `sendDatafastEvent` call and every `data-fast-goal` click attribute (the script sends those on its own), plus their tests and the code that only existed for them. ### Changes 🏗️ - Removed the in-app DataFast goals and checked each against PostHog: | DataFast goal (gone) | PostHog event that covers it | | --- | --- | | `onboarding_<step>` (team, autopilot, role, pain_points, connect, hire, preparing) | `onboarding_step_viewed` (`step`) | | `paywall_view` | `paywall_viewed` (`surface: onboarding`) | | `paywall_checkout_cancelled` | `checkout_abandoned` | | `hire_completed` (onboarding and expert page) | `expert_hired` (backend) | | `expert_fired` | `expert_fired` (backend) | | `add_to_library` | `listing_added_to_library` (backend) | | `download_agent` | `listing_downloaded` (backend; signed-in only) | | `workflow_installed_on_expert` | `workflow_installed_on_expert` (backend) | | `run_agent` (library, builder, re-run, run now) | `agent_run_started` (backend) | | `schedule_agent` | `schedule_created` (backend) | | `plan_cta_click` | `plan_selected` | | `subscription_upgrade_click` | `plan_selected` (`surface: billing`) | | `billing_portal_open` | `billing_portal_opened` | | `credit_topup_checkout` | `checkout_started` (`checkout_kind: top_up`, backend) | - **DataFast-only signals that PostHog doesn't get** (dropped on purpose, so nobody is surprised): - `paywall_billing_toggle`: switching monthly/yearly on the paywall, and the "Try Pro for N days instead" link. PostHog only sees the cycle on `plan_selected`. - `subscription_upgrade_intent`: the "Upgrade to Max" button on the model picker's locked-model card. PostHog only has its `$autocapture` click. - `subscription_cycle_confirm` / `subscription_change_confirm`: the confirm buttons in the billing settings dialogs. PostHog has the click before (`plan_selected`) and the result after (`subscription_changed` for upgrades, `checkout_started`), but no confirm-click event. It has no event at all for a monthly/yearly switch. - `download_agent` for signed-out visitors: `listing_downloaded` needs a user. - Deleted `activation-goals.ts` and its test. Removed the `goalSurface` prop from `SubscriptionPlans` and the `agentId` prop from the marketplace `AgentInfo`, both only used for DataFast. - Kept: the script, `datafast-server.ts`, `datafast-attribution.ts` (the `X-Datafast-*` headers), the backend Stripe `_datafast_metadata`, `UserAttribution`, and `sendDatafastEvent` with its queue. - `/tour`: no changes. Its goals (`tour_start`, `tour_scenario_start`, `tour_scenario_complete`, `tour_cta_click`) still go through `sendDatafastEvent`, so that helper and its queue stay until the page is removed. The marketplace sidebar CTA (logged-out) uses the same tour tracking and still sends `tour_cta_click`. - Docs: `docs/engineering/tracking-plan.md` now says what DataFast still gets and no longer mentions the in-app goals. Fixed the DataFast row in `activation-metrics.md`. ### Agents and large language models used - Claude Code — Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `pnpm types`, `pnpm lint`, `pnpm format` - [x] `pnpm vitest run` on onboarding, paywall, billing, builder, library, marketplace, team, analytics, `/tour` and the subscription plans. All pass except the billing trial-date tests (`billing-offer`, `payment-recovery`), which fail locally on `Sep 17` vs `Sep 18` because of the machine's timezone. They don't touch DataFast; CI runs in UTC. - [ ] On production after deploy: DataFast still records pageviews, `signup`, and revenue on paid checkouts, and gets no new in-app goals #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
046a4472b6 |
feat(backend): send Postmark email deliveries, opens, clicks and bounces to PostHog (#15850)
### Why / What / How **Why:** we send trial, billing, briefing and alert emails through Postmark but can't see whether anyone opens or clicks them. With these events PostHog can tie an email to what the user does next (G3: marketing email to the right people), e.g. an inactive user who comes back within 72 h of a click. Linear: [SECRT-2735](https://linear.app/autogpt/issue/SECRT-2735). Scope is email only; push, alerts and briefing events come later. **What:** the Postmark webhook handlers for delivery, open and click only logged. Now they, and the bounce handler, send `email_delivered`, `email_opened`, `email_clicked` and `email_bounced` to PostHog for the user the email went to. **How:** the recipient address is looked up among users; the user id is the `distinct_id`, and an address with no account sends nothing. The address is never sent to PostHog and is no longer logged. Each event carries Postmark's own time and a dedup key (message id + record type + that time), so a webhook Postmark redelivers counts once. ### Changes 🏗️ - `api/features/postmark/`: handlers send the four events with `email_type` (the Postmark tag), `message_stream`, `message_id`, plus `first_open`, `link` or `bounce_type`. `link` keeps only scheme, host and path; id- or token-like path segments become `:id`; non-web links keep only the scheme (`mailto:`). - `notifications/email.py`: every notification email is tagged with its type (`briefing`, `trial_update`, ...), auth email with `auth`. Each message now says whether Postmark tracks opens and clicks: on only when the new `POSTMARK_TRACK_OPENS_AND_CLICKS` setting is true (default off), links rewritten in the HTML part only. Auth email is never tracked, since its links carry sign-in tokens. - Before this, postmarker sent `TrackLinks: "None"` on every message, so click tracking was off whatever the Postmark stream said. - `posthog_client.capture` / `product_analytics.track` take an optional `timestamp`. - Events added to `posthog_events.py`, the pin test and `docs/engineering/tracking-plan.md`. The plan's `email_sent` is listed as a difference: Postmark's webhook reports delivery, not sending. - Webhook auth is unchanged: the `X-Postmark-Webhook-Token` header checked with a constant-time compare, failing closed when the token isn't configured. A test covers missing and wrong tokens. Configuration: new `POSTMARK_TRACK_OPENS_AND_CLICKS` (default `false`) in `.env.default` and `settings.py`. For opens and clicks to arrive, it must be `true` on the notification service, and the Postmark stream's webhook must have the Open and Click record types ticked (Delivery and Bounce for the other two events). ### Agents and large language models used - Claude Code — Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `webhook_test.py`: each record type sends its event for the right user without the address; an unknown address sends nothing; a redelivered webhook gets the same uuid and time; soft bounces keep the address verified; requests without the token are refused; link cleaning - [x] `email_test.py`: tags, tracking follows the setting, auth mail never tracked - [x] `posthog_events_test.py`, `posthog_client_test.py`, `product_analytics_test.py` #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
abcac99159 |
fix(backend): stop asking PostHog over the network about flags it doesn't have (#15765)
### Why / What / How
**Why:** AutoPilot reads several feature flags on every turn that
neither PostHog project defines. A flag the project doesn't have is
missing from the definitions the client evaluates locally, so the SDK
asks PostHog's `/flags` endpoint on **every read**. `/flags` has no
answer either, so the code default is served after a network round trip
of ~0.1 s.
Checked against each project's flag table (`system.feature_flags`) on
2026-10-20, these are missing in **both prod and dev**: `copilot-sdk`,
`copilot-skills`, `copilot-scheduled-followups`,
`copilot-unified-approvals`, `chat-connection-upsell`,
`copilot-stream-shadow`, `subscription-pricing-page-initial-state`.
(Switched-off flags such as prod's `copilot-auto-mode` are in the
definitions and are answered locally, so they cost nothing.) On every
SDK turn, before the model is asked:
- `copilot-sdk` for the engine choice (`resolve_use_sdk`): ~113 ms of
every turn on a preview with step probes.
- `copilot-skills` in the skills context and the skills update check.
- `copilot-scheduled-followups` in the session context
(`build_session_context`).
`copilot-unified-approvals` is read again when tools run.
**In prod** (Sentry, the 24 h to 2026-10-20 10:00Z): 323 `/flags` calls
(p50 116 ms) inside 149 `copilot.turn` transactions, ~2.2 per turn.
Another 687 calls sat inside 383 `/api/chat/connections` requests, which
the chat page loads: ~1.8 per request, against that endpoint's 1.1 s
p50. After this ships, `/flags` spans in both should fall to about one
per process per flag every five minutes.
This is part of the AutoPilot response-latency work.
**What:** once PostHog has had no answer for a flag its definitions
lack, the backend serves that flag's default without asking `/flags`
again for five minutes. The values served don't change: a flag PostHog
doesn't have was already served its default.
**How:** in `backend/util/feature_flag/posthog.py`, `evaluate_flag`
records a flag whose read came back with no answer, per client (a
`WeakKeyDictionary`, so a rebuilt client or a test's stub starts empty).
It then skips the read while that record is under
`UNANSWERED_RECHECK_SECONDS` (300) old **and** the loaded definitions
still lack the flag. Three cases keep today's behaviour:
- **A flag PostHog answers over `/flags` although the definitions leave
it out** keeps being asked on every read. That covers a flag in
PostHog's newer config format ("Feature Flag Rules v2"), which the
definitions SDKs poll leave out, as well as encrypted-payload and survey
flags.
- **A flag created after being recorded** is read from its definition
from the next poll on (30 s). Creating a default-on flag switched off (a
kill switch) therefore still takes effect at the next poll. PostHog's
definitions include switched-off flags, and the SDK evaluates them
locally as off.
- **While the definitions are unknown** (not loaded yet, or PostHog
refused the key), nothing is skipped.
Two behaviour changes, both limited to flags that only `/flags` answers.
None of the code's flags is one today: none is in the newer format
(checked in both projects' flag tables).
- A flag that is missing now and later created in the newer config
format takes up to five minutes to take effect, instead of the next
read. Flags the sync script creates are in the format the definitions
include.
- The SDK returns the same empty result for a `/flags` error (a timeout
or a 5xx) as for a flag that doesn't exist. So after an error, such a
flag is served its default for up to five minutes, instead of until the
next read. For every other flag nothing changes: an error already meant
the default. During a PostHog outage, a missing flag now costs one
timeout per five minutes instead of one per read.
An experiment read (`EXPERIMENT_FLAGS`) of a flag PostHog doesn't have
now asks `/flags` at most once per process every five minutes. Its
`$feature_flag_called` event, which for a missing flag only ever carried
an empty response, is sent at most that often.
`subscription-pricing-page-initial-state` is in neither project today.
Not changed here: `frontend.py` already skips every flag missing from
the definitions (`defined_flag_keys()`) and answers it `None`, so a
frontend flag in the newer config format would never be answered there.
The commit message also lists `copilot-model-routing`,
`copilot-cost-limits` and `copilot-tier-multipliers` as missing. That
came from a first check against `/flags`, which hides server-only flags
from browser-style requests and leaves out switched-off ones. Both
projects have those three, so their reads are answered locally. This
description has the list from the flag tables.
### Changes 🏗️
- `backend/util/feature_flag/posthog.py`: `UNANSWERED_RECHECK_SECONDS`,
the per-client record, `_known_unanswered`, and the two lines in
`evaluate_flag` that check and record it.
- `backend/util/feature_flag/posthog_test.py`:
`TestAFlagTheDefinitionsLeaveOut`, five tests against a real `Posthog`
client with `/flags` stubbed.
- `docs/engineering/feature-flags.md`: a paragraph on what happens to a
flag the project doesn't have.
No configuration changes, no new flags.
### End-to-end coverage
The tests run reads through a real SDK client: local evaluation from
definitions, then `/flags` (stubbed at `_get_flags_decision`) for what
the definitions can't answer. They count `/flags` calls:
- A flag PostHog has no answer for is asked about once in three reads
and served its default each time.
- With the recheck window at zero it is asked again.
- A flag `/flags` answers but the definitions lack is asked every time
and its answer is served.
- A flag recorded as unanswered and then created switched off is read
from its definition as off, without another `/flags` call.
- Nothing is skipped while the definitions are unknown.
### Agents and large language models used
- Claude Code with Claude Opus 5.5
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `backend/util/feature_flag/` 238 passed, 2 skipped;
`backend/api/features/feature_flags/` 25 passed. `ruff`, `black`,
`isort` and `pyright` are clean.
- [x] Mutations, each caught by the new tests (8 of 8): never skip;
never record; staleness inverted; no recheck window; ignore the
definitions; skip while the definitions are unknown; skip every flag the
definitions lack, recorded or not; record answered flags too.
- [x] Preview (probe build #15739 with step probes, before and after
this change, same account and prompts; 18 follow-ups each): **the engine
choice went from 113 to 6 ms per turn and the skills check from 262 to
153 ms** (p50, permutation p < 0.001 for both), so the setup before the
CLI starts is ~0.21 s shorter. Every other step stayed within the
run-to-run noise. Each process still asks once per flag every five
minutes; that read showed up once in 24 turns (383 ms).
#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <[email protected]>
|
||
|
|
10ad8bd3b7 |
fix(frontend): DataFast waits for consent on /tour and stops sending workflow names (#15752)
### Why / What / How **Why:** DataFast ran on `/tour` before anyone agreed to analytics, and some DataFast events carried workflow names that people typed themselves. Both go against the privacy work ([SECRT-2713](https://linear.app/autogpt/issue/SECRT-2713)): we only collect what visitors agreed to, and nothing personal goes to DataFast. **What:** DataFast now waits for consent on `/tour` like on every other page. The `run_agent`, `schedule_agent`, `add_to_library` and `download_agent` events send only the ID, no name. The events themselves stay (removing in-app DataFast events is SECRT-2718). **How:** Removed the `/tour` exemption from the loader and from `sendDatafastEvent`. The reload that cleared a tour-loaded script after leaving `/tour` without consent is no longer needed, so it is gone too. The goal helpers take a graph ID instead of an object with a name. ### Changes 🏗️ - `loading-policy.ts`: removed `isDataFastConsentExempt` and the `/tour` path check; `resolveAnalyticsLoading` no longer takes a path. - `index.tsx` / `useSetupAnalytics.ts`: DataFast events need analytics consent everywhere; removed the `data-consent-exempt` marker and the reload that used it. - `activation-goals.ts`: `trackAgentRunGoal` / `trackScheduleCreatedGoal` take a graph ID and send `{ id, surface }`. Updated all callers. - `AddToLibraryButton.tsx`, `useAgentInfo.ts`: `add_to_library` and `download_agent` send only `{ id }`. - `docs/engineering/tracking-plan.md`: the tour funnel note no longer says DataFast skips consent. - Tests: updated the loading policy, `sendDatafastEvent`, consent, tour tracking/share and run/schedule goal tests; added `useAgentInfo.test.tsx`. - `tour/chat/tracking.ts`: `sendDatafastEvent` now returns whether the event went out, and `tour_start`'s once-per-session guard is set only then, so a `tour_start` dropped before consent was known is retried on the next tour visit. Effect on reports: DataFast tour goals (`tour_start`, `tour_scenario_start`, `tour_scenario_complete`, `tour_cta_click`) will only count visitors who accepted analytics, so the DataFast tour funnel will look smaller from this release. Unlike PostHog, DataFast does not hold events until the visitor answers: on a first visit where the answer arrives after the page loads, `tour_start` and the first `tour_scenario_start` are not sent, so the DataFast and PostHog tour funnels will not match. Reports grouped by workflow name on the four goals above lose that breakdown. ### Agents and large language models used - Claude Code — Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `pnpm types`, `pnpm lint`, `pnpm format` - [x] `pnpm vitest run` on analytics, tour, library run/schedule, builder actions and marketplace tests (498 pass) - [x] Tour tests: no DataFast events before consent, events after consent #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
e1095ceae6 |
perf(frontend/copilot): virtualize the copilot transcript (#15740)
### Why / What / How
**Why.** The copilot transcript mounted every message. Opening or
switching to a long thread parsed every message's markdown (Streamdown)
in one go, and every streamed flush walked all mounted rows, even after
rows were memoised (SECRT-3005). Opening a 2,000-message thread on `dev`
blocks the main thread for about 1.1 s in a production build (2.5 s in
dev) and mounts 924 rows.
**What.** The transcript now renders through a windowed list
(`@tanstack/react-virtual`): only the rows on screen, six either side
and the newest turn are mounted. Scrolling, stick-to-bottom, "jump to
latest", the minimap, `TailSpacer`, load-older-messages and switching
sessions behave as before (checked against `dev` below).
**How.**
- **Rows stay in normal flow.** The list is one flex child of
`ConversationContent`; its own top/bottom padding stands in for the
unmounted rows, and a spacer fills the gap between the window and the
pinned newest turn. Rows keep `key={message.id}` and `data-message-id`,
sit in a `flex flex-col` wrapper, and keep the same 24/16 px gaps, so
layout and `use-stick-to-bottom` are unchanged. No transforms, so no new
stacking or containing blocks.
- **Height estimates are bucketed by text length**
(short/medium/long/huge, plus tool parts), as the ticket asks, so
measured rows don't snowball the window.
- **The list owns scroll anchoring.** Chrome switches its own scroll
anchoring off in any frame where an ancestor's padding changes, which is
every frame the window moves. So the list sets `overflow-anchor: none`
and, when a row sitting wholly above the viewport changes height
(entering the window from an estimate, or a code block or image
settling), moves `scrollTop` by the same amount. It leaves rows only
partly above alone, so a reply streaming below the fold never drags the
view. It does nothing while pinned to the bottom (StickToBottom holds
that).
- **Older history (LoadMoreSentinel).** On a prepend the window's offset
shifts with the arrived height, so the rows being read stay mounted.
After the sentinel's own restore, the list puts the old first row back
where it was on screen, measured from the DOM. This also fixes a case
`dev` gets wrong: when a page merges rows further down, the sentinel's
total-height delta is smaller than the height added above, and the
reader ends up thousands of pixels away (see the load-more check).
- **Minimap.** Ticks for rows in the DOM still use `scrollIntoView({
behavior: "smooth", block: "center" })`; a tick for an unmounted row
asks the transcript to `scrollToIndex(…, { align: "center" })`.
- **TailSpacer** looks its anchor row up on every measure, because a
remounted row is a new node.
- **Entrance animation.** Rows that only scroll back into the window
skip `animate-in`; new messages still animate.
- **No measurable viewport** (a hidden panel, jsdom) → every row
renders, as before.
### Changes 🏗️
- `ChatMessagesContainer.tsx`: the row loop becomes `renderRow`,
rendered through `VirtualTranscript`. Held-call result rows, which
render nothing, stay out of the list (an empty row would still take a
gap). The minimap gets `onJumpToUnmounted`.
- New `components/VirtualTranscript/` (`VirtualTranscript.tsx`,
`useVirtualTranscript.ts`, `helpers.ts`, tests).
- `TailSpacer.tsx`: re-queries its anchor row on each measure and keeps
its height while the row is unmounted.
- `ChatMinimap.tsx`: one optional prop, `onJumpToUnmounted`, used only
when the tick's row isn't in the DOM. This file is outside the four the
brief listed, but the Linear issue lists "ChatMinimap scroll-to
behaviour" in scope, and without it minimap clicks on far turns would do
nothing.
- `package.json` / `pnpm-lock.yaml`: `@tanstack/[email protected]`
added as a direct dependency. It was already in the lockfile as a
transitive dependency at that version, so the lockfile gains only the
importer entry.
- Perf tooling (shared files, minimal and append-only):
- `copilot-switch-threads` selector fix: the ChatSidebar lists chats as
buttons, so the scenario timed out on `dev`.
- New `copilot-open-long-thread` scenario (2,000 messages; the fixture
repeats the recorded 200 rows with fresh ids past 200, and below 200 it
is unchanged).
- Baseline and docs rows appended to `frontend-perf-baseline.md` and
`frontend-perf-measurement.md`.
### Evidence
All numbers are from this sandbox (8 vCPU, 16 GB) against the real app
on :3000, with infra and backend in docker. `pnpm perf` runs use a fresh
`NEXT_PUBLIC_RENDER_COUNTER=1 pnpm dev` per scenario and `--channel
chrome`, 1 warm-up + 3 runs. `before` = `dev` at `a7fe56d05`, `after` =
this branch.
#### `pnpm perf:compare` — opening a 2,000-message chat (new
`copilot-open-long-thread`)
### copilot-open-long-thread — median of 3 (before) vs 3 (after)
| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 27 | 35 | +8 (+29.6%) |
| total renders | 59,027 | 3,377 | -55,650 (-94.3%) |
| renders per commit | 2,186.1 | 96.5 | -2,089.6 (-95.6%) |
| max renders in one commit | 38,844 | 1,210 | -37,634 (-96.9%) |
| script time (ms) | 2,546 | 463 | -2,083 (-81.8%) |
| task time (ms) | 3,337 | 611 | -2,726 (-81.7%) |
| layout time (ms) | 72 | 7 | -65 (-90.1%) |
| style recalc time (ms) | 209 | 33 | -176 (-84.2%) |
| long tasks (≥ 50 ms) | 8 | 3 | -5 (-62.5%) |
| long task total (ms) | 3,007 | 443 | -2,564 (-85.3%) |
| heap used after GC (MB) | 119.8 | 67.7 | -52.1 (-43.5%) |
<details><summary>Top components</summary>
**Top components** (median renders)
| name | before | after | Δ |
| --- | --- | --- | --- |
| `Tooltip` | 3,337 | 97 | -3,240 (-97.1%) |
| `TooltipContent` | 3,337 | 97 | -3,240 (-97.1%) |
| `Streamdown` | 2,772 | 36 | -2,736 (-98.7%) |
| `Presence` | 2,303 | 143 | -2,160 (-93.8%) |
| `TooltipProvider` | 2,238 | 78 | -2,160 (-96.5%) |
| `Popper` | 2,235 | 75 | -2,160 (-96.6%) |
| `PopperAnchor` | 2,235 | 75 | -2,160 (-96.6%) |
| `PopperProvider` | 2,235 | 75 | -2,160 (-96.6%) |
| `Primitive.button` | 2,235 | 75 | -2,160 (-96.6%) |
| `Primitive.button.Slot` | 2,235 | 75 | -2,160 (-96.6%) |
| `motion.div` | 865 | 849 | -16 (-1.8%) |
`motion.div` (≈850) is the minimap's one framer-motion tick per user
message, now the largest mount on open. It is out of scope here and
listed as a follow-up.
</details>
#### `pnpm perf:compare` — `copilot-stream-reply` (200-message chat, one
streamed reply)
### copilot-stream-reply — median of 3 (before) vs 3 (after)
| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 378 | 472 | +94 (+24.9%) |
| total renders | 63,427 | 20,914 | -42,513 (-67.0%) |
| renders per commit | 168.9 | 44.4 | -124.5 (-73.7%) |
| max renders in one commit | 9,634 | 668 | -8,966 (-93.1%) |
| script time (ms) | 3,883 | 2,056 | -1,828 (-47.1%) |
| task time (ms) | 5,186 | 2,653 | -2,533 (-48.8%) |
| layout time (ms) | 169 | 53 | -116 (-68.7%) |
| style recalc time (ms) | 239 | 90 | -150 (-62.5%) |
| long tasks (≥ 50 ms) | 6 | 2 | -4 (-66.7%) |
| long task total (ms) | 1,901 | 187 | -1,713 (-90.1%) |
| heap used after GC (MB) | 86.4 | 63.6 | -22.8 (-26.4%) |
#### `pnpm perf:compare` — `copilot-switch-threads` (200 / 50 / 10,
three rounds)
### copilot-switch-threads — median of 3 (before) vs 3 (after)
| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 263 | 335 | +72 (+27.4%) |
| total renders | 132,083 | 32,104 | -99,979 (-75.7%) |
| renders per commit | 502.2 | 95.8 | -406.4 (-80.9%) |
| max renders in one commit | 11,998 | 1,068 | -10,930 (-91.1%) |
| script time (ms) | 7,008 | 3,230 | -3,778 (-53.9%) |
| task time (ms) | 9,302 | 4,354 | -4,947 (-53.2%) |
| layout time (ms) | 192 | 60 | -132 (-68.9%) |
| style recalc time (ms) | 535 | 225 | -310 (-57.9%) |
| long tasks (≥ 50 ms) | 23 | 19 | -4 (-17.4%) |
| long task total (ms) | 6,926 | 2,461 | -4,465 (-64.5%) |
| heap used after GC (MB) | 84.1 | 64.5 | -19.6 (-23.3%) |
Reading the tables honestly:
- Total renders, per-component counts and heap are deterministic and
drop on every scenario.
- **Commits go up** (+25–30%): the list re-renders on its own when rows
are measured or the window moves. Each of those commits is small, so
renders per commit and max renders in one commit fall sharply.
- These tables were re-measured on the latest head, after the review
fixes. The switch fix (no full-thread pass when a session switch
remounts the transcript) took `copilot-switch-threads` from 69,793 to
32,104 renders, and its max renders in one commit from 11,468 to 1,068.
#### Longest task when opening the 2,000-message chat — **production
build** (`next build && next start`), PerformanceObserver `longtask`, 3
runs
| | longest task (ms) | rows mounted | "Maximum update depth" warnings |
| --- | --- | --- | --- |
| before (`dev`) | 1,026 / 1,062 / 1,110 | 924 | 0 |
| after | **94 / 138 / 92** | **10** | 0 |
The ticket's target (no long task > 200 ms on opening the long thread)
is met in the production build. In the dev build the longest task is
245–356 ms (before: ~2.5 s). Most of what is left is dev-only StrictMode
double rendering, the minimap's ticks and converting 2,000 raw messages.
#### Negative checks — real browser, same script against `dev` and this
branch
| check | before (`dev`) | after |
| --- | --- | --- |
| Rows mounted on opening the 200-message chat | 168 | 10 |
| Scroll from bottom to top in 400 px steps; rows that moved by anything
other than the scroll | 5–14 steps drifted by up to 7 px | **0** |
| Pinned to the bottom while a reply streams (20 samples) | 18–19 / 20 |
17–20 / 20 (same run-to-run spread) |
| Scrolled up 1,500 px while a reply streams: reader's row moves by | 0
px | 0 px (4/4 runs) |
| Minimap → Turn 1 (far, unmounted) and back to Turn 84 | visible |
visible |
| "Jump to latest" button | at bottom | at bottom |
| Switch 200 → 50 → 10 chat | pinned to bottom each time | pinned to
bottom each time (9–10 rows mounted) |
| Copy on the newest reply | clipboard = reply text | clipboard = reply
text |
| Tool row ("Used tools") expand after minimap jump | expands | expands
|
| Load older messages, real backend, 400-message session, 4 pages; drift
of the row being read | 16, 16, **6,515, 6,616 px** | **0, 0, 7, 0 px**
|
| Console "Maximum update depth" / React warnings | 0 | 0 |
`dev`'s 16 px is the sentinel swapping its button for the spinner. Its
6,515 px drift happens when a page merges rows further down the list
(see How).
Screenshots, cropped to the transcript (the top bar showed the test
account):
| | before (`dev`) | after |
| --- | --- | --- |
| 2,000-message chat opened | 
| 
|
| After loading 4 older pages from the real backend (the reader was on
Turn 85–86) | 
| 
|
| Minimap jump to Turn 1 | 
| 
|
| Minimap jump to Turn 5, then "Used tools" expanded | 
| 
|
| After a streamed reply, pinned | 
| 
|
#### Tests
```
$ pnpm vitest run src/app/(platform)/copilot/components/ChatMessagesContainer src/app/(platform)/copilot/components/ChatMinimap scripts/perf
Test Files 30 passed (30)
Tests 346 passed (346)
VirtualTranscript
✓ mounts only the rows near the viewport of a 500-message thread
✓ keeps the list as tall as every row would be
✓ windows the oldest rows when the user scrolls to the top, keeping the newest turn
✓ mounts a message appended while pinned to the bottom
✓ does not move the scroll position when a message is appended while scrolled up
✓ keeps the rows on screen mounted when older history is prepended
✓ scrolls an unmounted message into the window by id
✓ skips the entrance animation for rows scrolling back into the window
✓ keeps the rows on screen still > moves the scroll position by the error of a row that enters above the viewport
✓ keeps the rows on screen still > leaves the scroll position to StickToBottom while pinned
✓ keeps the rows on screen still > puts the row being read back in place when older history arrives
✓ keeps the rows on screen still > does not move for a row that enters below the viewport
✓ keeps the newest user message mounted when its turn runs past eight rows
✓ windows rows when it mounts in the same commit as the scroll element
✓ renders every row when the scroll element has no height
ChatMinimap ✓ hands a message whose row is not mounted to the transcript
TailSpacer ✓ measures the row's new node after the transcript remounts it
```
I mutation-checked the two anchoring tests: each fails when its
compensation is disabled. The page-level copilot, ChatContainer, share,
builder, memory and expert-drawer suites pass (799 tests). 6 of them
first timed out at 60 s while the dev server competed for CPU, and all
79 tests in those files pass on a rerun.
### Behaviour notes
The ticket accepts these as part of windowing:
- A row that scrolls far out of the window unmounts. Its local UI state
(an expanded tool row, an opened reasoning panel) resets if the user
scrolls far away and back, and its markdown is re-parsed on re-entry.
Rows inside the window, plus the newest turn, keep their state as
before.
- Browser find-in-page (Ctrl+F) only sees mounted rows.
- A minimap jump to an unmounted row is instant; jumps to mounted rows
stay smooth.
- Load older messages now keeps the reader exactly in place (dev drifted
16 px, or lost the position entirely; see above).
### Follow-ups (out of scope, not fixed here)
- `ChatMinimap` mounts one framer-motion tick per user message (≈850 on
a 2,000-message thread). It is now the largest mount when opening a long
thread.
- `use-stick-to-bottom` does not un-pin on a wheel-up over a code block
(`<pre class="overflow-x-auto">`) while a reply streams: its wheel
handler stops at the first scrollable ancestor. Same on `dev`.
- `LoadMoreSentinel` restores by total height delta, which is wrong when
a page also merges rows below. The windowed list now corrects for it;
the sentinel's own logic is unchanged.
- Converting 2,000 raw messages
(`convertChatSessionMessagesToUiMessages`) costs about 23 ms on open.
### Agents and large language models used
- Claude Code (Conductor) with Claude Opus 5.5
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pnpm format && pnpm lint && pnpm types` clean
- [x] Targeted vitest: ChatMessagesContainer, ChatMinimap and
perf-script suites (346 tests), plus the copilot page-level,
ChatContainer, share-page, builder, memory and expert-drawer suites
- [x] `pnpm perf` before/after: `copilot-switch-threads`,
`copilot-stream-reply`, new `copilot-open-long-thread` (3 runs each)
- [x] Production build: longest task when opening a 2,000-message thread
- [x] Real browser, base vs change: stick-to-bottom while streaming;
position held while scrolled up during a stream; scroll from bottom to
top with no jumps; minimap to far/unmounted turns; jump to latest;
switching 200 → 50 → 10; copy; tool-row expand; load-older-messages
through 4 real backend pages (seeded 400-message session); no "Maximum
update depth" warnings
#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
|
||
|
|
6319f87b16 |
feat(backend/copilot): send AutoPilot's tool calls and replies to PostHog as AI traces (SECRT-2979) (#15605)
> Stacked on #15604 (one trace per reply). This PR targets that branch and gets retargeted to `dev` when #15604 merges. ### Why / What / How **Why:** PostHog's AI observability can't show which tools AutoPilot ran, which of them failed, or why a reply was slow. It only gets the model calls, which OpenRouter Broadcast sends. Langfuse shows all of that today, so this has to land before Langfuse tracing goes off ([SECRT-2711](https://linear.app/autogpt/issue/SECRT-2711)). This is step 2 of [SECRT-2979](https://linear.app/autogpt/issue/SECRT-2979), and it closes the checklist's **Tool calls** and **Trace structure** items. **What:** Each reply is now a full trace in PostHog LLM analytics: - **Trace root:** one `$ai_trace` per reply, sent when its turn ends. It has the turn's latency, whether it failed (with the error code) and how many tools it called. - **Tool calls:** one `$ai_span` per tool call. It has the tool's name, latency and whether it failed. Platform tools are covered on both engines, and so are the SDK CLI's own tools (Task, Bash, ...), except the high-volume lookups (`Read`, `Grep`, `Glob`, `TodoWrite`, ...). - **Expert subtasks:** a subtask or sub-session joins the trace of the reply it works for. It shows up as a span under the tool call that started it, and its own tool calls nest under it. - **Feedback:** a thumbs up/down or copy now carries the reply's `$ai_trace_id`, so a rating attaches to its reply rather than only to the chat. No event carries a tool's input or output, or an error message, so Gmail and Drive content stays out (Google Limited Use). An error is named only by its code or its exception type. **How:** - The new `copilot/ai_spans.py` sends everything through the existing `posthog_client.capture`. That is the same shared, batched client and the same server-side-for-everyone rule the generations and `$ai_metric` already follow. Each event is on the user's distinct id, carries `$ai_session_id` = the chat session, `impersonated`, and the `environment` base property `capture` adds. `capture` queues on the client's background thread and swallows failures, and the property building is wrapped too, so capture never slows down or breaks a turn. - **Trace id:** a child's envelope records the spawning turn's trace when the child is derived (`TurnEnvelope.ai_trace_id`, set in `_admitted_turn_envelope`). It uses the same rule as the Sentry trace hand-over: only a turn derived from the caller's context gets it. A child that waits in the turn queue and a subtask woken by an approval start under their stored envelope, so they keep it. `trace_id_for()` uses that recorded trace, falls back to the tree root (`envelope.tree_id`) for an envelope stored before the field existed, and otherwise uses the turn's own id. The executor sets the result as the turn's trace id. That is the same context variable #15604 added, so a subtask's generations (Broadcast) join the parent trace too. The tree root alone would be wrong after an approval wake: the woken reply is a new turn under the tree its call was held in. - **Nesting:** a context variable holds the current span. The executor sets it to the turn id. `BaseTool.execute` opens a span under it and makes that span current while the tool runs. A tool called by a tool nests under it, and a turn spawned from inside a tool records the tool's span on its envelope as `TurnEnvelope.ai_parent_span_id`. That uses the same rule as the Sentry trace hand-over: only a turn derived from the caller's context. - **Failures:** a tool counts as failed when its output is `success=False` (it raised, or a gate refused it) or when it answered with an `ErrorResponse`. A call stopped for an approval card or a sign-in (`ApprovalRequiredResponse`, `NeedLoginResponse`) waits on the user, so it doesn't count. A tool that raised also gets `$ai_error` = the exception's type name: `BaseTool` catches the exception, so it names it on the span itself (`mark_tool_raised`). A cancelled tool isn't counted as a failure; it gets `cancelled=true`. - **SDK built-ins** are timed between the existing PreToolUse hook (only once validation passed) and PostToolUse/PostToolUseFailure. MCP tools are skipped there because `BaseTool` already covers them, and so are `ai_spans.UNTRACED_BUILTIN_TOOLS`. A built-in's span is parented to the turn, not to the `Task` span of the sub-agent that called it. The timer lives in the turn's hooks closure, so a call whose PostToolUse never fires is dropped with the turn. - **Redelivery:** a redelivered turn (same `turn_id`) ends twice. `capture_turn` passes the turn id as `dedup_key`, so both sends of its `$ai_trace` (or subtask `$ai_span`) carry the same event uuid and PostHog keeps one. - **Feedback link:** the new nullable `ChatMessage.traceId` is stamped on the turn's assistant/reasoning rows, the same way `langfuseTraceId` is stamped: bounded to the turn, never overwritten, and back-filled for rows flushed mid-turn. On the SDK path the stamp runs at the end-of-turn persist. On the baseline path it runs at the mid-turn flush, the error-marker save and the final save. `feedback._send_to_posthog` adds `$ai_trace_id` when it's set. **Volume against the 100k events a month free tier.** Each reply now adds 1 `$ai_trace`, 1 `$ai_span` per platform tool call, 1 per traced built-in tool call and 1 per subtask. - **Built-ins** are the SDK CLI's own tools, and the hooks see every one of them, sub-agents' calls included. The CLI can run `TodoWrite`, `Read`, `Glob` and `Grep` tens of times a turn, so those, `LS` and `NotebookRead` get no span (`ai_spans.UNTRACED_BUILTIN_TOOLS`). Most of what's left is `Task`/`Agent`, one per sub-agent: `Bash`, `Write`, `Edit`, `WebFetch` and `WebSearch` are disallowed in the SDK (`SDK_DISALLOWED_TOOLS`), and their MCP replacements count as platform tools. Allow 0 to 2 traced built-ins per reply. `CHAT_POSTHOG_BUILTIN_TOOL_SPANS=false` sends no built-in spans at all. - **With generations:** every tool round is also a model call, so a reply with *N* tool calls (platform, traced and skipped built-ins) already sends up to *N*+1 generations. At a typical 3 to 8 platform calls and 0 to 2 traced built-ins, the new events take 4 to 11 per reply and the total is about 8 to 22 per reply. So 100k a month covers roughly 4.5k to 12k replies (about 150 to 400 a day). Skipped built-ins add generations but no span. - **Measuring the real count**, from data we already have: - Platform tool spans ≈ `chat_tool_called` (same call site, platform tools only), and `$ai_trace` ≈ the replies (`chat_message_sent`). - Built-ins never send `chat_tool_called`. Count them in the backend logs instead: each finished SDK tool call logs `[SDK] PostToolUse: <tool> (builtin=True, ...)`, and a failed one logs `[SDK] Tool failed: <tool>`. Those `builtin=True` lines, minus the skipped names, are the built-in span count. - After deploy, `$ai_span` events with `builtin = true` count them directly. The ticket's own check of the billing projection a week after deploy still stands. **Left out:** - `$ai_tools` isn't needed for the checklist. - A subtask's generations sit at the trace's top level rather than under its span: Broadcast forwards no parent span id for a generation. - A queued child or held call stored before this deploy has no recorded trace, so it joins its tree root's trace and hangs off the trace root. - The SDK's non-`BaseTool` MCP helpers (the oversized-result `Read`) get no span. ### Changes 🏗️ - `copilot/ai_spans.py` (new): - `trace_id_for`, `tool_span`, `mark_tool_failed`, `mark_tool_raised`, `ToolSpanTimer` (SDK hooks, with the `UNTRACED_BUILTIN_TOOLS` skip-list), `capture_turn` (`$ai_trace` / subtask `$ai_span`, deduplicated on the turn id) and `stamp_turn_trace_id`. - `tools/base.py`: - `BaseTool.execute` wraps every call in `tool_span`. - A tool that answers with an `ErrorResponse` marks its span failed; one that raised names the exception type; an approval card or sign-in request is not a failure. - `turn_tracing.py`: - `TurnTrace` records the engine, outcome and error code. - `turn_transaction` sends the turn's end once it has reached its engine. - `executor/processor.py`: - Sets the trace id (tree root for subtasks) and the turn span. - Passes the error code to the outcome. - `executor/utils.py`: - `_admitted_turn_envelope` records the spawning turn's trace and tool span on a child's envelope. - `tree.py`: new `TurnEnvelope.ai_trace_id` and `ai_parent_span_id` (observability only, not limits). - `sdk/security_hooks.py`: spans for the CLI's built-in tools; `create_security_hooks(builtin_tool_spans=...)`, which `sdk/service.py` passes from the config. - `config.py`: new `ChatConfig.posthog_builtin_tool_spans`. - Feedback link: - `schema.prisma` + migration `20261019120000_add_chat_message_trace_id` (dated after every migration on `dev`): nullable `ChatMessage.traceId`, a catalog-only change with no table rewrite. - `model.py` / `db.py`: `ChatMessage.trace_id` is persisted on insert and back-fill, the same way as `langfuse_trace_id`. - `sdk/service.py`, `baseline/service.py`: stamp it. - `feedback.py`: `$ai_metric` carries `$ai_trace_id`. - `util/posthog_events.py`: new `AI_TRACE`, `AI_SPAN` members, pinned in `posthog_events_test.py`. - `llm_attribution.py`: docstring only (a subtask's trace id is the trace of the turn that spawned it). - `docs/engineering/tracking-plan.md`: `$ai_trace` and `$ai_span` rows, `$ai_trace_id` on `$ai_metric`, `turn_origin` = `spawned` on subtask spans, the skipped built-ins and the switch, the redelivery dedup, and how the events join. - Configuration: `CHAT_POSTHOG_BUILTIN_TOOL_SPANS` (default `true`) turns the built-in tool spans off. Not in `.env.default`: the default is what every environment should run. ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `ai_spans_test.py` (new): span properties, no input/output/error text, nesting, concurrency, cancelled ≠ failed, outside a turn sends nothing, PostHog failures swallowed, built-in timer, skipped high-volume built-ins, the switch off, trace root vs subtask span, a redelivered turn sends one root / one subtask span, stamping - [x] `turn_tracing_test.py` (new): a turn that reached its engine sends its trace root (also when it raised); one that never did sends nothing - [x] `tools/base_test.py`: a tool call is a span without its input or output; `ErrorResponse` and exceptions mark it failed, and an exception is named by its type only - [x] `sdk/security_hooks_test.py`: built-in tool spans, failure; refused calls, MCP tools, `TodoWrite` and hooks built with `builtin_tool_spans=False` send none - [x] `config_test.py`: `posthog_builtin_tool_spans` defaults on and `CHAT_POSTHOG_BUILTIN_TOOL_SPANS=false` turns it off - [x] `executor/utils_tracing_test.py`: only a child derived from the caller records the spawning trace and tool span on its envelope; a re-admitted (woken) turn keeps the one its envelope recorded - [x] `feedback_test.py`, `model_test.py`, `db_test.py`: `traceId` round-trips and reaches `$ai_metric` - [x] Source pins: both engines stamp the trace before they save - [ ] `executor/processor_test.py` (subtask trace id and turn span; needs Redis, so it runs in CI) - Backend CI doesn't trigger on a PR into a stacked branch, so it was dispatched by hand on this branch (`gh workflow run platform-backend-ci.yml --ref kpczerwinski/secrt-2979-ai-spans`). - [ ] After deploy to dev: one reply with a tool call and an expert subtask shows as one trace in PostHog LLM analytics, with the generations, tool spans and the subtask nested; no tool content in any span; a thumbs-down on it carries the same `$ai_trace_id` #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
5684067ba2 |
perf(frontend/builder): isolate canvas renders, batch loads, memo block menu (#15726)
### Why / What / How **Why** (SECRT-3012, wave 3 of SECRT-2993): `<Flow>` subscribed to `nodes`, so every drag frame and every keystroke in a node field re-rendered the whole builder shell, including the builder chat panel (copilot transcript + composer), the reviews panel, the safe-mode toggle and the draft-recovery popup. Loading a saved workflow wrote the node store once per node. In the block menu, 14 whole-store `useBlockMenuStore()` reads plus an unmemoised `Block` row meant every search page, filter count and creator-list update re-rendered every row already in the list, with index keys on the search results. **What:** the canvas is now a leaf that alone reads `nodes`/`edges`; `addNodes` is one store write; `updateNodeData` skips writes that change nothing; block-menu rows are memoised with atomic store selectors and stable keys. Undo/redo, field edits, adding blocks from the block menu and loading a saved workflow behave exactly as before (checked side by side in a real browser below). **How:** - `Flow.tsx`: a `FlowCanvas` leaf holds the `nodes`/`edges`/`hasWebhookNodes` subscriptions and the React Flow callbacks. `Flow` keeps the data hooks (`useFlow`, `useFlowRealtime`, `useCopyPaste`) and renders `FloatingReviewsPanel` and `BuilderChatPanel` next to the canvas. `FloatingSafeModeToggle` and `DraftRecoveryPopup` are created in `Flow` and passed into the canvas as `children`, so a canvas re-render hands React the same elements and it skips them. The controls inside the canvas (`NewControlPanel`, `CustomControls`, banners) still render inside `FlowCanvas` exactly as before. That is deliberate: `UndoRedoButtons` reads `canUndo()` during render against the live node store, so keeping it on the node-driven re-render keeps the undo button's enabled state identical. - `nodeStore.ts` (only `addNodes` and `updateNodeData`): `addNodes` appends the whole batch in one `set` (no write for an empty list). `updateNodeData` returns early when every key already holds a deep-equal value, so no new `nodes` array, no canvas render and no draft save. A key the node lacks still counts as a change even when its value is `undefined`, and duplicate ids are still all updated, as before. - Block menu (`NewControlPanel/NewBlockMenu/**`): every `useBlockMenuStore()` becomes an atomic selector (`useShallow` for multi-field reads); `Block` is `memo`ised (`Block.Skeleton` kept); search results are keyed by `type-id` (a repeat falls back to its index, so keys stay unique); `unpaginate` is cached on the query data. - `historyStore.ts` is unchanged: see "Left out" below. ### Changes 🏗️ - `Flow.tsx`: split out `FlowCanvas`; panels no longer re-render on node/edge changes. - `nodeStore.ts`: one-write `addNodes`; no-op `updateNodeData` writes skipped. - `NewBlockMenu/**`: atomic store selectors, memoised `Block`, stable search keys (`getSearchItemKeys` in `helper.ts`), cached `unpaginate`. - Tests: `Flow.isolation.test.tsx`, `nodeStore.writes.test.ts`, `BlockMenuSearchContent.rerender.test.tsx`. 7 of their 18 tests fail on `dev` without this change. - Perf harness (`scripts/perf/scenarios/`, append-only `registry.ts`): new `builder-type-field`, `builder-block-menu-search` and `builder-load-graph` scenarios. Rows were appended to `docs/engineering/frontend-perf-measurement.md` and their `dev` baselines to `docs/engineering/frontend-perf-baseline.md`. These are the shared files I touched, all append-only. ### Evidence Real stack (docker backend, `NEXT_PUBLIC_RENDER_COUNTER=1 pnpm dev` on :3000, Chrome 141). 1 warm-up and 3 saved runs per side. `before` is `dev` at `8eb406275`, with this PR's app files swapped back as the measurement doc describes; `after` is this branch. The saved JSON normally keeps only the top 50 components, so I ran an untracked local copy of `measure.ts` with that limit raised, to read the panel counts below. Nothing else about the tool changed. **Panels per scenario window (renders, every run identical within ±1):** | component | drag-running before | after | type-field before | after | | --- | --- | --- | --- | --- | | `Flow` | 69 | 7 | 20 | **0** | | `FlowCanvas` (new leaf) | – | 68 | – | 20 | | `BuilderChatPanel` | 69 | **7** | 20 | **0** | | `FloatingReviewsPanel` | 70 | **8** | 20 | **0** | | `DraftRecoveryPopup` | 69 | **7** | 20 | **0** | | `FloatingSafeModeToggle` | 69 | **7** | 20 | **0** | | `UndoRedoButtons` | 1 | 1 | 20 | 20 | | `GraphSearchMenu` / `AgentOutputs` | 61 | 61 | 20 | 20 | Block menu search (`builder-block-menu-search`): `Block` rows 256 → **48** (all 48 are mounts), `BlockMenuContent` 7 → 1. Graph load: unchanged render counts (`CustomNode` 299 → 299). **Reading the numbers honestly:** render counts are deterministic. Timings: - `builder-type-field`: script time 702–929 ms before vs 605–686 ms after, and long tasks 4 → 0. The ranges don't overlap. - `builder-block-menu-search`: script time 195–274 ms vs 130–154 ms, renders −50%. Ranges don't overlap. - `builder-load-graph`: script time 3,022–3,244 ms vs 2,749–2,911 ms (about −6%) with no render change. One store write instead of 100 shows up only as script time. - `builder-drag-running`: renders −1.5%; script time −17%, but that is inside the after-run spread (15.8%), so call it likely, not proven. The drag's remaining cost is the nodes themselves plus `GraphSearchMenu`/`AgentOutputs`, which subscribe to `nodes` in files outside this ticket (follow-up). ### builder-drag-running — median of 3 (before) vs 3 (after) | metric | before | after | Δ | | --- | --- | --- | --- | | commits | 208 | 205 | -3 (-1.4%) | | total renders | 34,124 | 33,621 | -503 (-1.5%) | | renders per commit | 164.1 | 164.0 | -0.1 (-0.0%) | | max renders in one commit | 11,095 | 11,095 | 0 (0.0%) | | script time (ms) | 2,087 | 1,738 | -349 (-16.7%) | | task time (ms) | 3,555 | 3,155 | -400 (-11.3%) | | layout time (ms) | 46 | 44 | -2 (-4.9%) | | style recalc time (ms) | 77 | 74 | -3 (-4.5%) | | long tasks (≥ 50 ms) | 20 | 12 | -8 (-40.0%) | | long task total (ms) | 1,544 | 859 | -685 (-44.4%) | | heap used after GC (MB) | 91.7 | 89.8 | -1.9 (-2.1%) | <details><summary>Top components, causes and raw runs</summary> **Top components** (median renders) | name | before | after | Δ | | --- | --- | --- | --- | | `TooltipProvider` | 1,824 | 1,824 | 0 (0.0%) | | `Presence` | 1,701 | 1,701 | 0 (0.0%) | | `HugeiconsIcon` | 1,446 | 1,384 | -62 (-4.3%) | | `Icon` | 1,446 | 1,384 | -62 (-4.3%) | | `Text` | 1,368 | 1,368 | 0 (0.0%) | | `Tooltip` | 1,300 | 1,300 | 0 (0.0%) | | `TooltipContent` | 1,300 | 1,300 | 0 (0.0%) | | `Primitive.div` | 1,100 | 1,100 | 0 (0.0%) | | `Primitive.button` | 976 | 976 | 0 (0.0%) | | `Popper` | 935 | 935 | 0 (0.0%) | **Top render causes** (median renders) | name | before | after | Δ | | --- | --- | --- | --- | | `TooltipProvider props:children` | 1,824 | 1,824 | 0 (0.0%) | | `Presence props:children` | 1,701 | 1,701 | 0 (0.0%) | | `HugeiconsIcon parent-same-props<-Icon` | 1,446 | 1,384 | -62 (-4.3%) | | `Tooltip props:children` | 1,300 | 1,300 | 0 (0.0%) | | `Primitive.div props:children` | 915 | 915 | 0 (0.0%) | | `TooltipContent props:children` | 836 | 836 | 0 (0.0%) | | `Primitive.div.Slot props:children` | 823 | 823 | 0 (0.0%) | | `Primitive.div.SlotClone props:children` | 823 | 823 | 0 (0.0%) | | `Primitive.button.Slot props:children,onPointerMove,onPointerLeave,onPointerDown,+4` | 772 | 772 | 0 (0.0%) | | `Primitive.button.SlotClone props:onPointerMove,onPointerLeave,onPointerDown,onFocus,+4` | 772 | 772 | 0 (0.0%) | <details><summary>Raw runs</summary> | label | run | commit | commits | renders | renders/commit | script ms | long tasks | heap MB | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | before | 1 | |
||
|
|
160a72eccb |
perf(frontend/library): re-render only the new row when a run is added (#15713)
### Why / What / How **Why.** On a workflow's page ([SECRT-3010](https://linear.app/autogpt/issue/SECRT-3010), wave 2 of SECRT-2993), the runs list keyed its rows by index (`InfiniteList` used `key={idx}`). When a new run arrived at the top, every key shifted, so React re-rendered every card, including its actions menu and its two dialogs. Per-row state could also end up on the wrong run: keyboard focus on a row's menu button stayed in the same slot while the runs moved. The rows were not memoised either, and `onSelectRun` changed on every parent render, so the list's four auto-select effects re-ran each time too. The selected run kept polling every 1.5 s in a hidden tab (`refetchIntervalInBackground: true`). **What.** When a run is added, only the new row renders. Existing rows keep their elements and state. Run labels ("5 minutes ago") now keep up with time. The selected run stops polling while the tab is hidden. **How.** - `InfiniteList` takes an optional `getKey(item, index)`. It still falls back to the index, so `SkillsBrowsePage` (the only other user) does not change. The runs list keys by `run.id`. - New `TaskListRow` wraps `TaskListItem` in `memo`. Every refetch after a prepend hands each row a new `run` object even when nothing changed (React Query's structural sharing compares arrays by index). So the row compares `run` by value with `lodash/isEqual` and the other props by identity. - `SidebarRunsList` passes `useStableCallback(onSelectRun)` to the rows and to `useSidebarRunsList`, so the memo holds and the auto-select effects only re-run when their real inputs change. The callback still forwards to the latest `onSelectRun` (tested). - New `atoms/RelativeTime`. It subscribes to the shared `useNowMinute` clock (SECRT-2995, no new timer) and renders `formatDistanceToNow(date, { addSuffix: true })`. At any render the text matches the old call exactly. `TaskListItem` uses it, so memoised rows no longer freeze their label. Before, the label only changed when the whole list re-rendered. - `useSelectedRunView` drops `refetchIntervalInBackground: true`. While the run is RUNNING/QUEUED/INCOMPLETE/REVIEW it still polls every 1.5 s, and it resumes on the next interval tick after the tab is shown again. ### Changes 🏗️ - `components/molecules/InfiniteList/InfiniteList.tsx`: optional `getKey` prop. - `…/SidebarRunsList/SidebarRunsList.tsx`: key runs by id, render `TaskListRow`, stable `onSelectRun`. - `…/SidebarRunsList/components/TaskListRow.tsx` (new): memoised row. - `…/SidebarRunsList/helpers.ts` (new): `uniqueRunsByID`. Pages are fetched by offset, so a run started between two page loads can come back on both; with id keys that would be a duplicate React key, so the list shows each run once (flagged by Sentry). - `…/SidebarRunsList/components/TaskListItem.tsx`, `SidebarItemCard.tsx`: label via `RelativeTime`. `description` now takes a `ReactNode`, which is compatible with the string callers. - `components/atoms/RelativeTime/RelativeTime.tsx` (new). - `…/SelectedRunView/useSelectedRunView.ts`: no polling in background tabs. - Perf tooling, separate commit: a new scenario, `library-agent-runs`. `library-running` opens `/library`, which never renders these components. It adds 4 entries to `ROUTE_TABLE`, 1 to `scenarios/registry.ts`, a row in `frontend-perf-measurement.md` and a baseline section in `frontend-perf-baseline.md`. All of these are append-only edits to shared files. - Tests: `InfiniteList`, `RelativeTime`, `useSelectedRunView` (hidden tab), `runs-list-prepend` integration test, and the new routes in `routes.test.ts`. ### Evidence #### `library-agent-runs`: 20 runs, the newest RUNNING and selected (it polls), then 3 new runs arrive over the websocket, 2.5 s apart Total renders fell 68% and script time 55%; long tasks went from 6 to 1. Every run printed the same request counts on both sides: 3 run-list `GET`s and 5 run-detail `GET`s. On the `before` side, total renders swing by one full-list re-render (8,462 vs 9,722). In `after` run 1 the window crossed a minute boundary, so each of the 23 `RelativeTime` labels ticked once (+23 leaf renders, which is the intended behaviour). The rest of the remaining renders are mostly the selected run's view re-rendering on each poll; see follow-ups. #### `library-agent-runs` — median of 3 (before) vs 3 (after) | metric | before | after | Δ | | --- | --- | --- | --- | | commits | 40 | 39 | -1 (-2.5%) | | total renders | 9,722 | 3,148 | -6,574 (-67.6%) | | renders per commit | 237.1 | 80.7 | -156.4 (-66.0%) | | max renders in one commit | 1,252 | 339 | -913 (-72.9%) | | script time (ms) | 785 | 350 | -435 (-55.4%) | | task time (ms) | 978 | 507 | -472 (-48.2%) | | layout time (ms) | 9 | 8 | -1 (-8.6%) | | style recalc time (ms) | 13 | 9 | -3 (-27.0%) | | long tasks (≥ 50 ms) | 6 | 1 | -5 (-83.3%) | | long task total (ms) | 579 | 52 | -528 (-91.1%) | | heap used after GC (MB) | 43.3 | 43.1 | -0.2 (-0.5%) | **Top components** (median renders) | name | before | after | Δ | | --- | --- | --- | --- | | `Presence` | 926 | 236 | -690 (-74.5%) | | `Dialog` | 687 | 141 | -546 (-79.5%) | | `Text` | 390 | 106 | -284 (-72.8%) | | `DialogPortal` | 344 | 71 | -273 (-79.4%) | | `DialogPortalProvider` | 344 | 71 | -273 (-79.4%) | | `DialogProvider` | 344 | 71 | -273 (-79.4%) | | `BaseContent` | 343 | 70 | -273 (-79.6%) | | `DialogWrap` | 343 | 70 | -273 (-79.6%) | | `MenuProvider` | 302 | 26 | -276 (-91.4%) | | `Primitive.div` | 259 | 118 | -141 (-54.4%) | | `Primitive.button` | 238 | 97 | -141 (-59.2%) | | `Primitive.div.Slot` | 224 | 83 | -141 (-62.9%) | | `Primitive.div.SlotClone` | 224 | 83 | -141 (-62.9%) | | `Tooltip` | 132 | 120 | -12 (-9.1%) | | `TooltipContent` | 132 | 120 | -12 (-9.1%) | | `TooltipProvider` | 88 | 80 | -8 (-9.1%) | <details><summary>Raw runs</summary> | label | run | commit | commits | renders | renders/commit | script ms | long tasks | heap MB | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | before | 1 | |
||
|
|
04db8d6b52 |
perf(frontend/builder): by-id node reads, memo viewers, ref-driven beads (#15711)
### Why / What / How **Why.** In the builder, every node subscribes to the node store with by-id selectors (`getNodeStatus`, `getNodeErrors`, `getHardCodedValues`, `getNodeExecutionResults`, …), and each one ran `nodes.find` on every store update. That is O(nodes²) work per drag frame and per execution event. Edge beads were worse: `JSBeads` called `setState` on **every animation frame** while a bead moved, measured the SVG path again on every render, and re-ran its effect on every render, because a callback it depended on was recreated each time. Closed "View data" viewers also rebuilt their output items whenever their node re-rendered. **What.** By-id lookups use an index built once per `nodes` array. The output viewer hook memoizes its items. Bead positions live in a ref and are written straight to the `<circle>`s, so `JSBeads` re-renders only when a bead is created or removed. **How.** - `nodeStore.ts`: a module-level `WeakMap<nodes[], Map<id, node>>` built lazily, once per `nodes` array. The getters read from it instead of `find`. **Assumption:** the ticket asks for `statusById`/`errorsById`/`hardcodedValuesById` maps maintained alongside `nodes`. I derived one id index from `nodes` instead of adding three new state fields that every writer must keep in sync (`setNodes`, undo/redo, `updateNodeData`, `onNodesChange`, copy/paste, …). It cannot drift out of sync, the diff stays small for SECRT-3012, and every `NodeContainer`/`NodeExecutionBadge`/`NodeCost`/`ViewMoreData`/`useNodeOutput` selector gets the O(1) read without touching those files. Every store write already replaces the array; nothing mutates it in place (checked with grep). Like `find`, the index returns the first node when two share an id. - `NodeContainer.tsx`: the status is a string, so it uses a plain selector without `useShallow`. - `useNodeDataViewer.ts`: `outputItems` and `groupedExecutions` are `useMemo`'d on `data`/`executionResults`/`pinName`/`dataType`. Those inputs are immutable store references, so the memo never goes stale (`accumulateExecutionData` and the execution actions always copy). - `JSBeads.tsx`: the create/advance/destroy logic is unchanged, line for line (`advanceBeads` is the old `setBeads` updater). What changed is where the state lives: a ref instead of React state. `setState` now fires only when the bead count changes. Positions are written in the rAF loop and in a layout effect after each render, so a new circle, or an edge that moves while you drag, is placed before paint. The path is measured lazily, once per distinct `edgePath`, and edges with no beads never measure it. The effect depends only on `beadUp`/`beadDown`. - **One deliberate timing difference:** the loop now advances by the real frame delta, as its `deltaTime` code always intended. The old effect re-ran every render and reset the delta to a fixed 16 ms, so a bead took 500 ms only at exactly 60 fps: slower on a busy page, twice as fast at 120 Hz. You can see it in the 330 ms screenshots below: the "after" bead is closer to its target. - New perf scenario `builder-run-beads` (separate `dx` commit). None of the existing scenarios moves a bead, because `builder-run-events` reuses the fixture's `node_exec_id`s. The new one sends a second, finished run of nodes 30–39 with new ids. It reuses `builder-run-events`' socket helpers, which this PR now exports. Its baseline is added to `frontend-perf-baseline.md` and its row to the scenario table. ### Changes 🏗️ - `build/stores/nodeStore.ts`: `findNodeById` (a WeakMap id index), used by `getNodeStatus`, `getNodeErrors`, `getHardCodedValues`, `getNodeExecutionResults`, `getLatestNodeExecutionResult`. - `NodeContainer.tsx`: plain status selector. - `NodeDataViewer/useNodeDataViewer.ts`: memoized items; the grouping moves to a module-level `groupExecutions`. - `edges/components/JSBeads.tsx`: ref-driven animation, lazy path measurement, `setState` only on bead creation and removal. - Tests: `build/__tests__/nodeStore.byId.test.tsx`, `NodeDataViewer/__tests__/useNodeDataViewer.test.ts`, `edges/components/__tests__/JSBeads.test.tsx`. - Perf tooling (append-only shared edits): `scripts/perf/scenarios/builder-run-beads.ts` (new), one line in `registry.ts`, `export` on two helpers in `builder-run-events.ts`, a new row in `frontend-perf-measurement.md`, and a new section in `frontend-perf-baseline.md`. ### Evidence All numbers come from the real app on `:3000` (`NEXT_PUBLIC_RENDER_COUNTER=1 pnpm dev`, docker backend). The table is the second, alternating round: base files → `before2`, this branch's files → `after2`, with 2 warm-ups and 5 runs each, on the same dev server. (The `after2` rows show the base commit SHA because the branch's edits were uncommitted in the working tree when they were measured.) **Per-component renders for this ticket's components** (one run each, full ranking, so it covers components outside the top-10 tables below): | component / cause | `builder-run-beads` before | after | `builder-drag-running` before | after | | --- | --- | --- | --- | --- | | `JSBeads` (all renders) | 162 | **23** | 89 | 89 | | `JSBeads state#0` (per-frame bead state) | 150 | **12** | 0 | 0 | | `JSBeads props:edgePath,beadsKey` | 6 | 5 | 89 | 89 | | `NodeContainer` | 40 | 40 | 112 | 112 | | `NodeDataViewer` | 80 | 80 | 184 | 184 | | `CustomNode` | 30 | 30 | 112 | 112 | ### builder-run-beads — median of 5 (before2) vs 5 (after2) | metric | before2 | after2 | Δ | | --- | --- | --- | --- | | commits | 84 | 54 | -30 (-35.7%) | | total renders | 54,037 | 53,815 | -222 (-0.4%) | | renders per commit | 642.7 | 996.6 | +353.9 (+55.1%) | | max renders in one commit | 7,285 | 7,285 | 0 (0.0%) | | script time (ms) | 2,332 | 2,067 | -266 (-11.4%) | | task time (ms) | 2,791 | 2,369 | -422 (-15.1%) | | layout time (ms) | 39 | 26 | -13 (-33.3%) | | style recalc time (ms) | 31 | 24 | -7 (-22.4%) | | long tasks (≥ 50 ms) | 16 | 12 | -4 (-25.0%) | | long task total (ms) | 2,274 | 2,196 | -78 (-3.4%) | | heap used after GC (MB) | 99.4 | 100.0 | +0.6 (+0.6%) | **Top components** (median renders) | name | before2 | after2 | Δ | | --- | --- | --- | --- | | `Text` | 2,954 | 2,954 | 0 (0.0%) | | `TooltipProvider` | 2,819 | 2,815 | -4 (-0.1%) | | `HugeiconsIcon` | 2,675 | 2,671 | -4 (-0.1%) | | `Icon` | 2,675 | 2,671 | -4 (-0.1%) | | `Primitive.div` | 2,411 | 2,408 | -3 (-0.1%) | | `Tooltip` | 2,322 | 2,318 | -4 (-0.2%) | | `TooltipContent` | 2,322 | 2,318 | -4 (-0.2%) | | `Presence` | 1,561 | 1,556 | -5 (-0.3%) | | `SelectItem` | 1,512 | 1,512 | 0 (0.0%) | | `Primitive.div.Slot` | 1,427 | 1,424 | -3 (-0.2%) | **Top render causes** (median renders) | name | before2 | after2 | Δ | | --- | --- | --- | --- | | `TooltipProvider props:children` | 2,759 | 2,755 | -4 (-0.1%) | | `HugeiconsIcon parent-same-props<-Icon` | 2,323 | 2,319 | -4 (-0.2%) | | `Tooltip props:children` | 2,282 | 2,278 | -4 (-0.2%) | | `SelectItem props:onMouseDown,children` | 1,512 | 1,512 | 0 (0.0%) | | `Presence props:children` | 1,461 | 1,456 | -5 (-0.3%) | | `Primitive.div props:children` | 1,437 | 1,434 | -3 (-0.2%) | | `Primitive.div.Slot props:children` | 1,407 | 1,404 | -3 (-0.2%) | | `Primitive.div.SlotClone props:children` | 1,407 | 1,404 | -3 (-0.2%) | | `Popper props:children` | 1,269 | 1,266 | -3 (-0.2%) | | `PopperAnchor props:children` | 1,269 | 1,266 | -3 (-0.2%) | <details><summary>Raw runs</summary> | label | run | commit | commits | renders | renders/commit | script ms | long tasks | heap MB | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | before2 | 1 | |
||
|
|
49ab37bfc6 |
perf(frontend/copilot): select UI store fields and resize once per frame (#15709)
### Why / What / How
**Why.** Eleven `useCopilotUIStore()` calls took the whole copilot UI
store with no selector, so *any* store write re-rendered every one of
them. That includes `useCopilotPage`, which re-renders the whole chat
host and transcript. `PanelResizeHandle` called `onWidthChange` on every
`pointermove`, and the store does `set({ contextPanelWidth })` each
time. So dragging the files or artifact panel re-rendered the entire
200-message transcript on **every pointer move**: about 3,000 renders
per commit, 60 long tasks and about 18 s of script time for a 1-second
drag.
**What.** Each store reader now subscribes to only the fields it uses,
and the resize handle writes the width at most once per animation frame.
Nothing users can see changes: the panel follows the pointer, clamps at
the same limits, keeps the final width and persists it exactly as
before.
**How.**
- Atomic selectors (`useCopilotUIStore((s) => s.field)`) replace the
destructured whole-store reads, field for field. Every component still
reads exactly the fields it read before, so any write it cares about
still re-renders it. Writes to fields it does not read no longer do.
Actions are stable in zustand, so selecting them is free.
- `PanelResizeHandle` keeps the latest width in a ref and schedules one
`requestAnimationFrame` per frame to report it. The pending width is
committed **synchronously** on pointer up/cancel (both the element and
document listeners), and when the handle unmounts mid-drag. Because that
happens before `setIsDragging(false)`, the panel applies the final width
while its width tween is still off, the same as before. No store change
was needed: `setContextPanelWidth` already debounces its `localStorage`
write.
Ticket: [SECRT-2999](https://linear.app/autogpt/issue/SECRT-2999) (wave
2 of SECRT-2993).
### Changes 🏗️
- `copilot/components/PanelResizeHandle.tsx`: one width write per
animation frame, last width committed on pointer up/cancel or unmount.
- Store call sites converted to selectors (call-site lines only, nothing
else touched in these files):
- `useCopilotPage.ts:129`, `useWorkflowImportAutoSubmit.ts:106`,
`useSessionDeletion.ts:30`
- `ChatInput/ChatInput.tsx:166`, `ChatInput/useChatInput.ts:42`,
`ConnectionPicker/useConnectionPicker.ts:21`,
`ConnectionPicker/useLockedTierFallback.ts:10`
- `ChatSidebar/ChatSidebar.tsx:62`, `NotificationToggle.tsx:21`,
`NotificationDialog.tsx:13`, `MobileDrawer/MobileDrawer.tsx:38`
- Tests (new): `components/__tests__/PanelResizeHandle.test.tsx`,
`__tests__/store-selectors.test.tsx`,
`__tests__/useCopilotPage-store-selectors.test.tsx`, plus one `describe`
appended to `ChatSidebar.test.tsx`.
- Tests (updated): the four ChatInput test files that mock the store as
`() => ({...})` now apply the selector to the same object (`(selector)
=> selector({...})`).
- Perf tooling (append-only): new scenario
`scripts/perf/scenarios/copilot-resize-drag.ts`, registered at the end
of `scenarios/registry.ts`, with a row in
`docs/engineering/frontend-perf-measurement.md` and its baseline section
in `docs/engineering/frontend-perf-baseline.md`.
**Scope note.** The workspace brief listed only `useCopilotPage`,
`ChatInput` and `ChatSidebar:62` as call sites in scope, but the Linear
ticket names all 11. Converting only those three would have changed
nothing measurable. `useCopilotPage` also subscribes to the whole store
through `useWorkflowImportAutoSubmit`, `ChatSidebar` through
`useSessionDeletion`, and `ChatInput` through `useChatInput` and
`ConnectionPicker`. So all 11 call sites are converted, each a
one-line-to-few-line change at the store read only. None of the files
belong to an in-flight wave 2 ticket except `useCopilotPage.ts`, where
the diff is 3 lines at line 129, away from SECRT-2997's pipeline
(~300-331) and `onSend`.
### Evidence
All numbers come from `pnpm perf` on the real app
(`NEXT_PUBLIC_RENDER_COUNTER=1 pnpm dev` on :3000, backend in docker),
with 1 warm-up and the stated number of saved runs. "before" is `dev` at
`6d793e53d`; "after" is this branch's code on the same base. (The
`commit` column in the raw runs reads `6d793e53d` for both because the
"after" runs were taken from the working tree before committing.)
#### `copilot-resize-drag` (new scenario): open the files panel, drag
the handle 120 px with 60 moves over 1 s
### copilot-resize-drag — median of 3 (before) vs 3 (after)
| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 193 | 67 | -126 (-65.3%) |
| total renders | 574,244 | 1,986 | -572,258 (-99.7%) |
| renders per commit | 2,975.4 | 29.6 | -2,945.7 (-99.0%) |
| max renders in one commit | 9,498 | 214 | -9,284 (-97.7%) |
| script time (ms) | 17,819 | 318 | -17,501 (-98.2%) |
| task time (ms) | 20,508 | 2,191 | -18,317 (-89.3%) |
| layout time (ms) | 575 | 504 | -71 (-12.3%) |
| style recalc time (ms) | 34 | 26 | -8 (-24.1%) |
| long tasks (≥ 50 ms) | 60 | 0 | -60 (-100.0%) |
| long task total (ms) | 19,661 | 0 | -19,661 (-100.0%) |
| heap used after GC (MB) | 86.5 | 85.1 | -1.4 (-1.6%) |
**Top components** (median renders)
| name | before | after | Δ |
| --- | --- | --- | --- |
| `Tooltip` | 41,418 | 6 | -41,412 (-100.0%) |
| `TooltipContent` | 41,418 | 6 | -41,412 (-100.0%) |
| `HugeiconsIcon` | 24,264 | 190 | -24,074 (-99.2%) |
| `Icon` | 24,264 | 190 | -24,074 (-99.2%) |
| `Presence` | 22,172 | 132 | -22,040 (-99.4%) |
| `Popper` | 21,204 | 8 | -21,196 (-100.0%) |
| `PopperAnchor` | 21,204 | 8 | -21,196 (-100.0%) |
| `PopperProvider` | 21,204 | 8 | -21,196 (-100.0%) |
| `Primitive.button` | 21,204 | 8 | -21,196 (-100.0%) |
| `Primitive.button.Slot` | 21,204 | 8 | -21,196 (-100.0%) |
| `motion.div` | 6,247 | 63 | -6,184 (-99.0%) |
| `AnimatePresence` | 3,190 | 64 | -3,126 (-98.0%) |
| `PopChild` | 3,130 | 64 | -3,066 (-98.0%) |
| `PopChildMeasure` | 3,130 | 64 | -3,066 (-98.0%) |
| `PresenceChild` | 3,130 | 64 | -3,066 (-98.0%) |
| `Dialog` | 968 | 124 | -844 (-87.2%) |
| `Text` | 803 | 129 | -674 (-83.9%) |
**Top render causes** (median renders)
| name | before | after | Δ |
| --- | --- | --- | --- |
| `Tooltip props:children` | 41,298 | 6 | -41,292 (-100.0%) |
| `HugeiconsIcon parent-same-props<-Icon` | 24,264 | 190 | -24,074
(-99.2%) |
| `Presence props:children` | 22,172 | 132 | -22,040 (-99.4%) |
| `Primitive.div props:children` | 21,204 | 8 | -21,196 (-100.0%) |
| `Primitive.div.Slot props:children` | 21,204 | 8 | -21,196 (-100.0%) |
| `Primitive.div.SlotClone props:children` | 21,204 | 8 | -21,196
(-100.0%) |
| `Popper props:children` | 20,955 | 5 | -20,950 (-100.0%) |
| `PopperAnchor props:children` | 20,955 | 5 | -20,950 (-100.0%) |
| `PopperProvider props:children` | 20,955 | 5 | -20,950 (-100.0%) |
| `TooltipProvider props:children` | 20,949 | 3 | -20,946 (-100.0%) |
| `PopChild props:children` | 3,130 | 64 | -3,066 (-98.0%) |
| `PopChildMeasure props:children` | 3,130 | 64 | -3,066 (-98.0%) |
| `AnimatePresence state#5` | 2,170 | 64 | -2,106 (-97.1%) |
| `Icon parent-same-props<-PanelEmptyState` | 0 | 183 | +183 |
| `Text parent-same-props<-PanelEmptyState` | 0 | 122 | +122 |
| `BaseContent props:children` | 0 | 62 | +62 |
| `Dialog props:onOpenChange,children` | 0 | 62 | +62 |
| `Dialog props:styling,controlled,children` | 0 | 62 | +62 |
<details><summary>Raw runs</summary>
| label | run | commit | commits | renders | renders/commit | script ms
| long tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before | 1 |
|
||
|
|
6c89b1607f |
perf(frontend/copilot): tick the thinking timer from one shared clock (#15685)
### Why / What / How
**Why.** `ChatMessagesContainer` called
`useElapsedTimer(isActivelyStreaming, …)` at the transcript root. That
hook is a 1 s `setInterval` with `setState`, so the whole conversation
(every row, every tool chain, the pending-answer contexts) re-rendered
once a second for the whole turn. That includes long tool calls where no
tokens arrive and the elapsed label isn't on screen at all:
`ThinkingIndicator` only shows it after 20 s. On the 200-message chat
each tick costs about 9,000 component renders and a 270 ms long task in
dev.
**What.** One shared wall-clock ticker (`useNowSecond`, plus
`useNowMinute` for later label work). The clock moves into
`ThinkingIndicator`, which ticks only while it is shown. The transcript
keeps just the turn's start time, and the finished turn's elapsed value
is frozen in state instead of in a ref read during render.
**How.**
- `src/hooks/useNowSecond.ts` / `useNowMinute.ts`:
`useSyncExternalStore` over a single `setTimeout` aligned to the
wall-clock second/minute. It starts on the first subscriber and stops on
the last. While the timer runs, `getSnapshot` returns the cached tick,
so every subscriber gets the same value in the same tick.
`useNowSecond(false)` neither subscribes nor re-renders.
- `JobStatsBar/useElapsedTimer.ts`: new `useElapsedAnchor(isRunning,
anchorIso)` returns `{ startedAtMs, frozenSeconds }` and does not tick.
The run start is re-synced in render on start/stop or when a late
`anchorIso` arrives, which is the same rule the old effect used.
`frozenSeconds` is captured when the run stops. `useElapsedTimer` keeps
its signature and behaviour and is now `useElapsedAnchor` +
`useNowSecond`. Its one remaining caller, `PendingUploadMessage`, ticks
in step with the indicator.
- `ThinkingIndicator`: new optional `startedAtMs`. When it is set, the
indicator computes elapsed itself from `useNowSecond(active)`. The
existing `elapsedSeconds` prop still works, so `PendingUploadMessage`
and the tour page need no change.
- `ChatMessagesContainer.tsx`, timer lines only: `useElapsedTimer` →
`useElapsedAnchor` for the stream and for the restore fallback. The
dependency-less effect and `frozenElapsedRef` are deleted, and
`TurnStatsBar` gets `streamClock.frozenSeconds`. The row loop is
unchanged apart from that one prop read.
No behaviour change is intended: same labels, same 20 s threshold, same
anchor and re-sync rules, same freeze/reset rules for "Thought for Xs".
One sub-second difference: ticks now land on the wall-clock second
rather than on mount + n·1000 ms, so a reading can flip up to 1 s later
than before. The label only appears after 20 s and shows whole seconds.
### Changes 🏗️
- New `src/hooks/useNowSecond.ts`, `src/hooks/useNowMinute.ts` (shared
ticker).
- `JobStatsBar/useElapsedTimer.ts`: `useElapsedAnchor` +
`getElapsedSeconds`; `useElapsedTimer` rebuilt on the shared clock.
- `ChatMessagesContainer/components/ThinkingIndicator.tsx`: owns its
clock via `startedAtMs`.
- `ChatMessagesContainer.tsx`: timer lines (~435-462), the
`TurnStatsBar` prop read (~696) and the restore-fallback indicator prop
(~800).
- Tests: `hooks/__tests__/useNowSecond.test.tsx`,
`ThinkingIndicator.test.tsx`,
`JobStatsBar/__tests__/useElapsedTimer.test.tsx`,
`ChatMessagesContainer/__tests__/thinkingClock.test.tsx`. Each existing
`useElapsedTimer` mock in `ChatMessagesContainer.test.tsx` and
`expert-threads.test.tsx` gained one `useElapsedAnchor` line.
- Perf tooling, shared files with append-only edits: new
`copilot-tool-wait` scenario
(`scripts/perf/scenarios/copilot-tool-wait.ts`, fixture
`tool-call-wait.sse`), one line in `scenarios/registry.ts`, one row in
`frontend-perf-measurement.md`, and its dev baseline appended to
`frontend-perf-baseline.md`. The measurement doc's "Before and after"
section also gained a short note on measuring a scenario that the change
itself adds (swap only `src/` files back to the base). That came from
CodeRabbit's review.
### Evidence
All numbers: `NEXT_PUBLIC_RENDER_COUNTER=1 pnpm dev` on :3000, Chrome
headless (`--channel chrome`), same machine. "before" = `dev` at
`a1578ad9a`. "after" = this branch's working tree before it was
committed, which is why the raw-runs `commit` column still says
`a1578ad9a`.
#### `copilot-tool-wait` (new): a turn sitting on a long tool call, no
tokens, 20 s
### copilot-tool-wait — median of 3 (before) vs 3 (after)
| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 45 | 5 | -40 (-88.9%) |
| total renders | 183,109 | 429 | -182,680 (-99.8%) |
| renders per commit | 4,069.1 | 85.8 | -3,983.3 (-97.9%) |
| max renders in one commit | 9,134 | 214 | -8,920 (-97.7%) |
| script time (ms) | 5,475 | 41 | -5,434 (-99.3%) |
| task time (ms) | 7,248 | 2,360 | -4,889 (-67.4%) |
| layout time (ms) | 0 | 0 | 0 |
| style recalc time (ms) | 190 | 262 | +72 (+37.7%) |
| long tasks (≥ 50 ms) | 20 | 0 | -20 (-100.0%) |
| long task total (ms) | 5,439 | 0 | -5,439 (-100.0%) |
| heap used after GC (MB) | 91.8 | 85.1 | -6.7 (-7.3%) |
**Top components** (median renders)
| name | before | after | Δ |
| --- | --- | --- | --- |
| `Tooltip` | 13,532 | 12 | -13,520 (-99.9%) |
| `TooltipContent` | 13,532 | 12 | -13,520 (-99.9%) |
| `HugeiconsIcon` | 7,754 | 14 | -7,740 (-99.8%) |
| `Icon` | 7,754 | 14 | -7,740 (-99.8%) |
| `TooltipProvider` | 6,786 | 6 | -6,780 (-99.9%) |
| `Presence` | 6,780 | 20 | -6,760 (-99.7%) |
| `Popper` | 6,776 | 16 | -6,760 (-99.8%) |
| `PopperAnchor` | 6,776 | 16 | -6,760 (-99.8%) |
| `PopperProvider` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.button` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.button.Slot` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.button.SlotClone` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.div` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.div.Slot` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.div.SlotClone` | 6,776 | 16 | -6,760 (-99.8%) |
**Top render causes** (median renders)
| name | before | after | Δ |
| --- | --- | --- | --- |
| `Tooltip props:children` | 13,532 | 12 | -13,520 (-99.9%) |
| `HugeiconsIcon parent-same-props<-Icon` | 7,754 | 14 | -7,740 (-99.8%)
|
| `TooltipProvider props:children` | 6,786 | 6 | -6,780 (-99.9%) |
| `Presence props:children` | 6,780 | 20 | -6,760 (-99.7%) |
| `Primitive.div props:children` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.div.Slot props:children` | 6,776 | 16 | -6,760 (-99.8%) |
| `Primitive.div.SlotClone props:children` | 6,776 | 16 | -6,760
(-99.8%) |
| `Popper props:children` | 6,770 | 10 | -6,760 (-99.9%) |
| `PopperAnchor props:children` | 6,770 | 10 | -6,760 (-99.9%) |
| `PopperProvider props:children` | 6,770 | 10 | -6,760 (-99.9%) |
| `MenuProvider props:scope,children` | 0 | 12 | +12 |
<details><summary>Raw runs</summary>
| label | run | commit | commits | renders | renders/commit | script ms
| long tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before | 1 |
|
||
|
|
ec951d81c6 |
fix(backend): preserve concrete memory details in Luna extraction (#15677)
### Why / What / How
Luna's Graphiti extraction can drop concrete details embedded in
requests, leaving recent episode text as their only retrievable copy.
The pinned upstream system prompt also forbids abstract concepts while
our custom instructions require specific Concept, Preference, and Rule
entities.
Replace that conflicting system policy for Luna's four known entity/edge
extraction contracts. Retain concrete values, qualifiers, request
status, and subject-qualified relationship anchors while preserving the
upstream source context, schema, language handling, and retries. Model
routing and request parameters are unchanged.
Follow-up to #15656; related to #15622. The recall issue #15655 and the
shared fact-only forgetting failure documented in the report remain
separate.
The live AgentProbe memory-component comparison found:
| Configuration | Normal memory checks | Fact-only checks | Complete
normal probes |
| --- | ---: | ---: | ---: |
| Mini | 132/137 | 104/137 | 31/34 |
| Luna, original prompts | 129/137 | 79/137 | 30/34 |
| Luna, migrated prompts | 131/137 | 125/137 | 31/34 |
No normal/fact-only check passed with original Luna and failed with the
candidate in this run. Ten additional controls scored 61/61 with both
normal and fact-only memory for the candidate. Normal answer quality
does **not** establish strict parity with mini: pricing abstention and
shipping arithmetic differences remain despite the necessary facts being
retrieved. The full report retains raw scores, exclusions, sensitivity
checks, latency/cost tradeoffs, and provider-limit failures. A later
unvalidated wording experiment was reverted.
[Evaluation report and per-probe
evidence](https://github.com/Significant-Gravitas/AutoGPT/blob/codex/graphiti-luna-prompts-15622/docs/engineering/graphiti-luna-prompt-evaluation.md).
The evidence supports improvement over the Luna configuration on dev,
not a blanket no-regression certification or a full-chat benchmark.
Migration references: [prompt auditing and model-specific
evaluation](https://developers.openai.com/api/docs/guides/latest-model#prompting-best-practices),
[request
compatibility](https://developers.openai.com/api/docs/guides/latest-model#migration-quickstart).
### Changes 🏗️
- Add a scoped extraction policy for message, JSON, text, and edge
extraction on Luna, including Flex.
- Preserve explicit other models, non-extraction tasks, attribute
extraction, retry feedback, and source messages.
- Add dispatcher/client regressions, a guard exercising upstream
extraction entry points, and an opt-in paid three-source regression with
coherent per-fact assertions, exact payment values, and
scenario-specific checks for unsupported signing and company-attribution
claims. Register only those three intentional paid-test skips in the CI
policy.
- Share bounded assertion helpers between offline and paid tests without
importing model or database clients into the offline test module.
- Record the mini/original-Luna/migrated-Luna comparison and remaining
quality gaps. No configuration or schema changes.
### Agents and large language models used
- Codex through T3 Code — gpt-6-astra (implementation and review).
- gpt-4.1-mini and gpt-6-luna — live extraction/ranking evaluation;
gpt-6-luna was the fixed answerer.
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Graphiti non-integration suite after review fixes: 398 passed, one
pre-existing xfail. Thirty assertion false passes and two
coordinated-subject false rejections reproduced across these follow-ups;
retaining the upstream system preamble is also rejected by a checked
negative control.
- [x] Current assertion suite: 65 passed with 100% statement coverage of
the shared helper, three paid cases skipped; includes clause-bound
payment values and signing instructions, explicit counterparty detail
claims, and valid wording controls including negated instructions with
coordinated subjects. Four saved migrated-Luna captures of the
incidental-detail scenario pass offline replay with the tightened checks
(no new model calls).
- [x] Initial focused client/prompt suite: 65 passed; 100% statement
coverage of the prompt dispatcher and adapter. Three paid tests skip by
default.
- [x] Initial opt-in live regression: message, JSON, and text ingestion
all passed against an isolated FalkorDB; six durable details inspected.
This run predates the stricter per-fact assertions.
- [x] Live 31-scenario, 35-probe comparison with
normal/fact-only/memory-off controls, plus ten additional synthetic
cases.
- [x] 40-candidate reranking, three runs per model: Luna recall@4 1.0,
mini 0.5; all completed within eight seconds.
- [x] Native Black/isort/Ruff and scoped Pyright; secret scans and diff
checks passed.
- [x] Revalidated all twelve downloaded CI reports against the corrected
exact skip allowlist for Python 3.11/3.12/3.13.
- [ ] Rerun the tightened paid assertions after the provider daily quota
is available. Production prompt strings and request behavior are
unchanged by the review follow-ups.
- [x] Hosted CI on
|