76 Commits
Author SHA1 Message Date
Nicholas TindleandClaude Opus 5.5 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 7ff101a.
  - [x] After #16419 (Better Auth 1.7.12) merged, with `dev` merged in:
- `microsoft-sign-in-flow.test.ts` on 1.7: before the fix, the
unconfirmed-address and team-address refusals got no redirect, and
accounts were keyed on `oid` alone.
- With the fix, `vitest` over `lib/auth`, login, signup, onboarding,
`api/auth`, `components/auth` and the auth callback: 1,249 passed.
- `pnpm types`, `pnpm lint` and `pnpm format` pass, detect-secrets
passes on the changed files, and backend ruff, isort and black are
clean.
  - [x] Live, on a local stack with the dev Entra app:
- [x] A work account that signed in with Microsoft lands on "Also
connect Microsoft 365?", connects through Microsoft's consent screen,
sees each service's status, and "Continue" moves the wizard on.
- [x] A personal account whose address belongs to an existing AutoGPT
account is refused: a personal Microsoft account on
[email protected] went through Microsoft's consent and came back
to /login with "An AutoGPT account already uses that email. Log in the
way you usually do.", and no Microsoft login was linked to that account.
Under the old rule it would have joined it.

- [x] After #16459 (payment as the `ENABLE_PLATFORM_PAYMENT` setting)
and #15853 (PostHog-only flags) merged, with `dev` merged in on
2026-11-03:
- Conflicts in `useOnboardingLayout.ts`, its test and the default mocks.
The step placement freezes on its own inputs (the step-order flag and
the sign-in lookup), and only the paywall rule follows #16459's payment
rule: off until a read succeeds, so a failed read that later succeeds
moves the access step behind the paywall and nothing else moves (Sentry
caught an earlier version that left the whole placement live). Tests
cover both cases, and breaking any of the four rules turns a test red.
- All four `vitest` shards under `TZ=UTC`: 1,185 files passed. `pnpm
types`, `pnpm lint` and `pnpm format` pass, and `openapi.json` matches
the export.
- Backend, against private Postgres, Redis and RabbitMQ: all of
`backend/api` plus the paywall-route, architecture and tool-schema
guards and this PR's suites, 6,085 passed. The 18 failures all come from
the local setup and are in code this PR doesn't touch: no ClamAV for
dev's fail-closed virus scanner (17) and a one-node Redis cluster (1).
CI has both.

- [x] After #16577 (Better Auth 1.7.16, the Nov 3 security release) and
#16511 (one role token set for colours) merged, with `dev` merged in on
2026-11-04:
    - The only conflict was the `.secrets.baseline` timestamp.
- The Microsoft sign-in flow tests, which run Better Auth's real
handler, pass on 1.7.16. This PR's 32 frontend files use none of the
colour names #16511 removed, and `pnpm lint` passes with its new rules.
- `vitest` over `lib/auth`, login, signup, onboarding, `api/auth`,
`components/auth`, the auth callback and the Microsoft panel: 1,318
passed, and `pnpm types` passes. Both ran on the first merge
(`ad22a2d`); nine more `dev` commits merged cleanly after that, sharing
only `openapi.json` with this PR. CI checks the final head, and
`openapi.json` matches the export there.
- Backend, against private Postgres, Redis and RabbitMQ: all of
`backend/api` plus the paywall-route, architecture and tool-schema
guards, the Copilot executor and background-subtask tests, and this PR's
suites. 6,501 passed; the same 18 failures as before all come from the
local setup (no ClamAV, a one-node Redis cluster).

- [x] After SECRT-3384 (new sign-ups go to onboarding, not Home) merged,
with `dev` merged in again on 2026-11-04:
- The conflicts were only the login and signup hooks' imports: dev
replaced `sanitizeAuthNext` with `getAuthDeepLink`, which the bodies
already used. The Microsoft sign-in passes the callback the same
filtered link, so a new Microsoft account also lands on onboarding,
where the Microsoft 365 step is.
- `vitest` over `lib/auth`, `auth-redirect`, login, signup, onboarding,
`api/auth`, `components/auth`, the auth callback, `PaywallGate`,
`TopUpPrompt`, the onboarding provider and the Microsoft panel: 1,474
passed, including dev's new onboarding-first tests that render the login
page with the Microsoft button. `pnpm types` and `pnpm lint` pass.
- Backend, against private Postgres, Redis and RabbitMQ: all of
`backend/api`, dev's changed guards (architecture, tool schema, the new
secrets guard), the Copilot executor tests and this PR's suites. 6,675
passed; the same 18 failures as before come from the local setup (no
ClamAV, a one-node Redis cluster).

- [x] After #16629 (the SECRT-3384 follow-up) merged, with `dev` merged
in on 2026-11-05:
- #16629 sends OAuth round trips the whole sanitized `next` (`safeNext`,
tracking parameters included) and lets `/auth/callback` decide. The
merge keeps `providerCallbackPath` and passes it `safeNext`, so Google's
callback URL is the one #16629 builds (its exact-string tests pass) and
Microsoft's adds `provider=microsoft` for sign-up attribution.
- `vitest` over the same areas plus #16629's new onboarding-provider
tests: 1,537 passed. `pnpm types` and `pnpm lint` pass.
- Backend: all of `backend/api`, the guards and the Copilot executor
tests (which cover dev's two `processor.py` changes beside this PR's
error code): 6,798 passed; the same 18 local-setup failures.

- [x] After #16308 (plan-first trial, hire step removed) merged, with
`dev` merged in on 2026-11-06:
- #16308 retires the hire step and shows the plan on its own when there
is no paywall. The merge takes `hire` out of `DEFAULT_STEP_ORDER`, so an
experiment can't bring it back, and shows the plan step wherever #16308
does. The access step still comes first by default and still follows the
paywall when payments are on. The longest layout is still 8 steps:
self-hosted, with the plan, connect and access steps.
- `vitest` over the same areas: 1,662 passed, all 45 onboarding files
included. `pnpm types` and `pnpm lint` pass, and `openapi.json` matches
the export.
- Backend: all of `backend/api` (dev's new onboarding plan tests
included), the guards and the Copilot executor tests: 7,120 passed; the
same 18 local-setup failures.

- [x] Review round (`c4a9807d78`), then `dev` merged in again on
2026-11-09 (`4b9c1ca07d`, 21 commits, no conflicts):
- Continue with Microsoft asks for `openid profile email` only: with the
profile photo off nothing calls Graph, so `User.Read` only stored an
unused Graph token. A refused Microsoft sign-in returns to `/login` with
the page's sanitized `next`. The access step's sign-in lookup is cached
per account, so a tab that switches account looks again. The tracking
plan lists `access` and says the step order varies. Each new test fails
with its fix undone.
- `vitest` over the same areas: 1,723 passed. `pnpm types` and `pnpm
lint` pass, and `openapi.json` matches the export.
- Backend: all of `backend/api`, every architecture guard (dev added
one), the Copilot executor tests, dev's OAuth refresh-failure suites
(#15081) beside the Microsoft integration suites, and this PR's suites:
7,414 passed; the same 18 local-setup failures.

- [x] After #15459 (self-serve account deletion) merged, with `dev`
merged in on 2026-11-10 (`ed4d09a856`):
- #15459 sends every provider's refused sign-in to /login and shows its
own notice for a banned account. The merge keeps one error page for
every provider (the login page with the sanitized `next`), marks
Microsoft's with `provider=microsoft`, and shows the Microsoft copy and
the "Email Not Allowed" dialog only for those, so a Google refusal never
mentions Microsoft and other providers' codes show nothing, as on dev.
#15459's banned notice comes before the Microsoft copy. Both new flags
are kept. New tests cover a Google refusal and a banned Microsoft
account.
- `vitest` over the same areas plus the feature flags: 1,829 passed.
`pnpm types` and `pnpm lint` pass, and `openapi.json` matches the
export.
- Backend, the same set: 7,632 passed; the same 18 local-setup failures.

#### 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**): `AUTH_MICROSOFT_CLIENT_ID`,
`AUTH_MICROSOFT_CLIENT_SECRET` and `NEXT_PUBLIC_AUTH_MICROSOFT_ENABLED`
in `frontend/.env.default`, commented out; 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]>
2026-11-10 08:11:26 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-11-10 07:09:45 +00:00
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]>
2026-11-10 06:39:32 +00:00
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 (9e8d14e).
On the frontend a small `useInsufficientReply` hook holds what was
heard, the take's mode and `isFollowUp`, and the screen's buttons reuse
`handleRestart`, `showTyping` and `handleSkip`. Otto never moves (the
design system allows a blink only); the typing dots and the fade are
skipped with reduced motion, and Otto's text is in the DOM from the
first render.

### Changes 🏗️

- **Backend** (`onboarding_dump`): `insufficient_content` rejects return
`transcript_preview` (stripped, at most 280 characters) for voice and
typed; `no_usable_speech` never does. New `stt_artifacts.py` holds a
documented `STT_ARTIFACT_PHRASES` set ("thank you", "thanks for
watching", "please subscribe", "bye bye", "you", "music", "applause",
…), matched after lowercasing and stripping punctuation. A voice take
made of nothing but these phrases, one or several ("Thank you. Bye."),
is `no_usable_speech` with no judge call; a take with any other word
still goes to the judge, and typed text always does. Thresholds, prompts
and error codes are unchanged.
- **Otto's line fits what was said** (9e8d14e): a fixed set of written
lines, one per kind of too-short take (mic check, greeting, unsure,
reluctant, wants to skip and so on). On an `insufficient_content`
reject, Jev (TypeSafe) picks the kind and returns it as
`otto_reply_kind` on `FinalizeResponse`, a typed enum. No model text is
shown. No pick, any Jev failure, or `other` (no kind fits) gives the
default line. The quality gate itself is unchanged.
`brain_dump_insufficient_shown` gains `reply_kind`. The lines and fit
checks are in the comment below.
- **Not-enough screen**: `InsufficientState` rebuilt from `YourMessage`,
`OttoReply`, `TypingDots` and `InsufficientActions`, with the copy in
`insufficient-copy.ts`. No `StepHeader` headline on this screen.
- **After it**: the typing headline is the big question with "A few
sentences is plenty." and the placeholder `Like: {example}` (new
optional `placeholder` prop on `TypingComposer`/`TypedFallback`); the
recording screen shows a compact Otto bubble above the timer
(`OttoQuestionCard`).
- **Prompts**: `offYourPlateExample` per role and
`offYourPlateExampleForRole`, falling back to Other.
- **Tracking**: new `brain_dump_insufficient_shown` (`mode`,
`error_code`, `heard`, `reply_kind`, `prompt_set`) for voice and typed;
`from: "insufficient"` on `brain_dump_restarted`,
`brain_dump_typed_fallback` and `brain_dump_skipped` from this screen;
`follow_up: true` on `brain_dump_completed` after it. Existing events
keep their names and properties. Tracking plan and the live-name test
updated.
- **Tests**: new `InsufficientState.test.tsx`; new cases in
`useBrainDumpStep.test.ts`, `prompts.test.ts`, the onboarding page test
(follow-ups, tab order, events), `quality_test.py` and
`service_test.py`. The old "Thank you for watching." judge test now
covers a non-exact outro.
- **Merged with `dev`** (plan-first trial, #16308): one conflict in
`useBrainDumpStep.ts`, resolved by keeping both sides.

### Screenshots

Today's screen first, then this branch. Each image is 1440 on the left
and 390 on the right, captured from the real page against a mocked
backend.

| | 1440 and 390 |
| --- | --- |
| **Before** (today's screen) | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/01-before.png"
width="640" alt="Before, today's screen"> |
| Words heard | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/02-words-heard.png"
width="640" alt="Words heard"> |
| No words | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/03-no-words.png"
width="640" alt="No words"> |
| Typed, too little | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/04-typed.png"
width="640" alt="Typed, too little"> |
| Mic blocked | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/05-mic-blocked.png"
width="640" alt="Mic blocked"> |
| Otto typing (first 900 ms) | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/06-otto-typing.png"
width="640" alt="Otto typing, first 900 ms"> |
| Recording after the screen | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/07-recording-after.png"
width="640" alt="Recording after the screen"> |
| Typing after the screen | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/08-typing-after.png"
width="640" alt="Typing after the screen"> |
| 1280x720, no scrolling (words heard, recording after) | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/09-1280x720.png"
width="640" alt="Words heard and recording after the screen at
1280x720"> |

Every case was checked at 1440x900, 1280x720, 390x844 and 320x640: no
sideways scroll anywhere, and no vertical scroll at 1280x720. At 320x640
the screen scrolls a little, like the rest of the step. Re-captured
after merging `dev`: the screens are unchanged. The images live only in
commit 5ad1164; the next commit removes them, so nothing reaches `dev`.

### Event walk

Captured from the browser console with PostHog debug mode on
(`ph_debug`, what `posthog.debug()` sets), walking the real page against
a mocked backend. PostHog's own `$` properties are left out. Recorded
before 9e8d14e, so `brain_dump_insufficient_shown` below has no
`reply_kind` yet.

```text
voice reject, Talk, Send
  brain_dump_started             {"prompt_set":"Founder/CEO"}
  transcription_failed           {"error_code":"insufficient_content","finalize_latency_ms":4}
  brain_dump_insufficient_shown  {"mode":"voice","error_code":"insufficient_content","heard":true,"prompt_set":"Founder/CEO"}
  brain_dump_restarted           {"from":"insufficient"}
  brain_dump_started             {"prompt_set":"Founder/CEO"}
  brain_dump_completed           {"duration_secs":4,"input_mode":"voice","finalize_latency_ms":4,"prompt_set":"Founder/CEO","prompts_seen":1,"follow_up":true}

voice reject, Write, I'm done
  brain_dump_started             {"prompt_set":"Founder/CEO"}
  transcription_failed           {"error_code":"insufficient_content","finalize_latency_ms":4}
  brain_dump_insufficient_shown  {"mode":"voice","error_code":"insufficient_content","heard":true,"prompt_set":"Founder/CEO"}
  brain_dump_typed_fallback      {"reason":"chose_to_type","prompt_set":"Founder/CEO","from":"insufficient"}
  brain_dump_completed           {"input_mode":"typed","chars":30,"prompt_set":"Founder/CEO","prompts_seen":1,"follow_up":true}

typed reject, Continue without it
  brain_dump_typed_fallback      {"reason":"chose_to_type","prompt_set":"Founder/CEO"}
  brain_dump_insufficient_shown  {"mode":"typed","error_code":"insufficient_content","heard":true,"prompt_set":"Founder/CEO"}
  brain_dump_skipped             {"prompt_set":"Founder/CEO","prompts_seen":1,"from":"insufficient"}
```

### 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] Frontend: `pnpm format`, `pnpm lint`, `pnpm types` clean after
merging `dev`; the onboarding and analytics test files pass (70 files).
Before the merge, the full `pnpm test:unit` run passed apart from the
live event-name pin test, fixed by adding the new name
- [x] Backend after merging `dev`: `black`/`isort` and `pyright` clean
on the changed files; non-database `onboarding_dump` tests (778 at
44c4525, after merging `dev` again) and `architecture_test.py` pass.
`test_block.py` passes apart from 13 blocks that need ClamAV or outbound
SMTP, which this sandbox lacks (no block changes here). The
`*_db_test.py` files need the Docker test stack, so CI runs them; none
of them reach the changed code
- [x] Each absence test was checked against the mutation it guards
(preview sent on every reject, artifact match on typed text, substring
match, no artifact check, skip not ending a follow-up) and fails under
it
- [x] Each case (words heard, no words, typed, mic blocked), and the
recording and typing screens after it, at 1440x900, 1280x720, 390x844
and 320x640: no sideways scroll at any size, and 1280x720 fits without
scrolling in every case
  - [x] Event walk with PostHog debug on (above)
- [x] Keyboard: Back → Skip for now → Talk → Write → Continue without
it. Accessibility tree: Otto's label, first line and question are in an
`alert` while the dots show; the dots are `aria-hidden`
- [ ] Gate eval (`brain_dump_gate_eval.py`): not run, as it needs live
speech-to-text and judge API keys this environment does not have. Worth
running before merge.

#### 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; colours use role tokens (`bg-surface`, `bg-accent-surface`,
`border-brand/15`, `bg-brand/20`)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`
- [x] No change to `src/components/**`, `globals.css` or `tailwind*.ts`;
screenshots of the changed screen are in the section above
- [x] User-facing copy says Otto, never agent or graph

Resolves SECRT-3361

---
_Generated by [Claude
Code](https://claude.ai/code/session_01G58r43gKL1t2whqRCQXASw)_

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Toran Bruce Richards <[email protected]>
2026-11-09 20:39:34 +00:00
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]>
2026-11-06 10:39:04 +00:00
Toran Bruce RichardsandClaude Opus 5.5 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]>
2026-11-04 11:08:19 +00:00
Nicholas TindleandClaude Fable 5.1 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]>
2026-11-04 11:01:15 +00:00
Abhimanyu YadavandClaude Opus 5.5 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]>
2026-11-04 06:51:22 +00:00
Reinier van der LeerandClaude Opus 5.5 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]>
2026-11-04 05:08:20 +00:00
Abhimanyu YadavandClaude Opus 5.5 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` 606cb1b, which includes #16513 (Text, Icon
and Button variants), so before = `dev` 606cb1b and after = this branch
at 1ae437b. Both are production builds (`NEXT_PUBLIC_PW_TEST=true`) on
the same seeded database (17 marketplace workflows, the skills catalog,
one library workflow, one hired expert, an admin user), captured with
`scripts/screenshot-pages.ts` at 1440x900 and 390x844 and compared with
pixelmatch at threshold 0. 43 routes, 86 screenshots: **63 identical, 23
with changed pixels**. Each one was checked by eye: the colour changes
are the ones the mapping already listed (the merge with #16513 added
none), and the rest is capture noise (clock, animation, the sidebar
blur-in, an image loading). All images, diffs and crops:
[`evidence/secrt-3154`
r2](https://github.com/Abhi1992002/AutoGPT/tree/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2)
([compare
table](https://github.com/Abhi1992002/AutoGPT/blob/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/compare.md));
the first round, against `dev` e681d74, is in the same branch.

| Route | 1440 changed px | 390 changed px | Result |
| --- | --- | --- | --- |
| `/login` | 0 | 0 | No visible change |
| `/signup` | 0 | 0 | No visible change |
| `/reset-password` | 0 | 0 | No visible change |
| `/tour/chat` | 8378 | 934 | Noise: the animated dot-grid background;
the title gradient is the same colour. |
| `/home` | 0 | 0 | No visible change |
| `/copilot` | 0 | 0 | No visible change |
| `/marketplace` | 3966 | 3965 | Small: sky skill-tile icon backgrounds
sky-50 → info-surface. |
| `/marketplace/agent/autogpt/business-ownerceo-finder` | 1283 | 1005 |
Small: the info card's gradient frame ends indigo-200 → indigo/30
(near-identical). |
| `/marketplace/creator/autogpt` | 1331 | 1119 | Small: the creator
card's gradient frame ends indigo-200 → indigo/30 (near-identical). |
| `/marketplace/skills` | 47046 | 23995 | Small: "Operations"/"Support"
category chips sky-50 → info-surface; the rest of the 1440 count is the
sidebar blur-in (noise). |
| `/marketplace/search?searchTerm=lead` | 11255 | 11254 | Small:
"Operations"/"Support" category chips sky-50 → info-surface. |
| `/library` | 0 | 9164 | Noise (390 only): 1–4-level antialiasing
across the one workflow card (it has an image, so no placeholder
gradient is drawn). Placeholder gradients for workflows without an image
move to role tints (slight). |
| `/library/agents/4c71847c-b6c7-4ceb-afcb-a7aed4d07e11` | 0 | 0 | No
visible change |
| `/library/skills` | 0 | 0 | No visible change |
| `/library/followups` | 0 | 0 | No visible change |
| `/team` | 0 | 0 | No visible change |
| `/team/autopilot` | 0 | 0 | No visible change |
| `/team/ea5951f8-8f57-4f97-8c59-336b81388c27` | 0 | 0 | No visible
change |
| `/build` | 0 | 0 | No visible change |
| `/build?flowID=f6138aaf-4f85-48d5-b905-db11a2ff82f3` | 18 | 0 | Small:
the edge arrowhead, #555 → #505057. |
| `/artifacts` | 23563 | 0 | Noise (1440 only): the base run caught the
sidebar mid blur-in. |
| `/raise` | 17935 | 3165 | Animated dither shader never holds still
(both runs); compared by eye: no change. |
| `/settings/profile` | 0 | 0 | No visible change |
| `/settings/account` | 0 | 0 | No visible change |
| `/settings/api-keys` | 0 | 0 | No visible change |
| `/settings/integrations` | 0 | 0 | No visible change |
| `/settings/billing` | 0 | 0 | No visible change; the page shows
"Application error" in **both** builds on this sandbox (no billing
provider configured) |
| `/settings/creator-dashboard` | 0 | 0 | No visible change |
| `/settings/memory` | 0 | 0 | No visible change |
| `/settings/bots` | 0 | 0 | No visible change |
| `/profile/credits` | 0 | 0 | No visible change; 404 in both builds
(the page moved to `/settings/billing`) |
| `/profile/dashboard` | 0 | 0 | No visible change |
| `/admin/dashboard` | 0 | 0 | No visible change |
| `/admin/diagnostics` | 3482 | 2192 | Small: "Queued in RabbitMQ" dot
yellow-100/500 → yellow/30 and yellow (near-identical); info-list
headings keep their colours (orange-700 → amber-700 is #B46000 →
#B45309); the rest is the "Last updated" clock. |
| `/admin/platform-costs` | 0 | 0 | No visible change |
| `/admin/spending` | 0 | 0 | No visible change |
| `/admin/execution-analytics` | 0 | 0 | No visible change |
| `/admin/memory` | 6513 | 6492 | **Bigger:** "Ratification" button teal
→ green (success); "Nightly batch" button indigo-700 → indigo-500
(lighter). |
| `/admin/bots` | 0 | 780 | Noise (390 only): recharts lays its axis
line out a few px apart between runs; the chart file is exempt and
unchanged. |
| `/admin/marketplace` | 0 | 0 | No visible change |
| `/admin/block-cost-estimates` | 0 | 0 | No visible change |
| `/admin/users` | 756 | 0 | Noise (1440 only): the route redirects to
Home in this sandbox, and the base run caught the Otto avatar before it
loaded. |
| `/admin/rate-limits` | 0 | 0 | No visible change; redirects to Home in
both builds |

**Bigger visual changes** (a colour changing completely):
- **Admin → Memory** (`/admin/memory`): "Ratification" button teal →
green (`bg-teal-600`/`700` → `bg-success`/`success-foreground`);
"Nightly batch" button indigo-700 → indigo-500 (`bg-indigo`, lighter;
hover indigo/90).
- **Settings → Billing** (`/settings/billing`, plan card cycle toggle):
the "Save %" gradient text ends in sky instead of teal (`to-teal-500` →
`to-sky`). Not captured: the page errors on this sandbox in both builds.
- **Admin → Diagnostics** (`/admin/diagnostics`, with orphaned runs
selected): the "(N orphaned)" count inside the red "Stop selected"
button goes pale orange → white at 80% (`text-orange-200` →
`text-action-foreground/80`).

**Small visual changes** (shade shifts within the same hue; most only
show with data or on hover):
- Shared: Navbar activity dropdown yellow-600 → yellow-700
(`yellow-foreground`), its hover grey #F3F4F6 → #EFEFF0; attention card
caution icon yellow-600 → yellow-700; briefing card gradient purple-800
→ violet-700; pending-reviews and trial-card text #1F1F20 → #141414;
dialog/sheet backdrops unchanged (black is #141414 in this app, now
`scrim`).
- `/marketplace`, `/marketplace/skills`, search: sky chips/tiles sky-50
→ blue-50 (`info-surface`), text sky-800 → sky-700; gradient frames
indigo-200 → indigo/30.
- `/team`, `/team/[expert]`: setup status and workflow-card chips sky-50
→ blue-50, sky-600/800 → sky-700.
- `/settings/creator-dashboard`: "Needs changes" pill and delete action
rose → red (`error-surface`, `error-foreground`, `error-hover`), stats
tile sky-50 → blue-50.
- `/profile/credits`: pending-change banner and tier notice
violet-800/900 → violet-700 (`accent-strong`), border violet-300 →
brand/40.
- `/admin/diagnostics`, `/admin/platform-costs`,
`/admin/execution-analytics`, `/admin/marketplace`: orange-700/800/900 →
amber-700 (`warning-foreground`), yellow-600/800/900 → yellow-700,
pink-600/800/900 → pink-700; tinted rows orange-50/100 → orange/10, /30.
- `/admin/spending`: MAX tier chip indigo-100/700 → indigo/15 and
violet-700 (`accent-strong`).
- `/admin/memory`: invalidation row border rose-200 → error/20.
- `/build`: node status rings yellow/orange-300 → yellow/orange at 70%,
execution badges border yellow/orange-400 → -500, yellow-800 →
yellow-700, review badge surface yellow-50 → amber-50; edge arrowhead
#555 → #505057; tutorial icons #52525B → #68686F; block drag preview
#F4F4F5/#27272A → #EFEFF0/#3E3E43.
- `/library`: placeholder gradients for workflows without an image move
to role tints; Files drag ghost greys move to roles.
- `/home`: "needs you" work item yellow-50/200 → amber-50/200.
- `/copilot`: orbit loader balls #18181B/#A1A1AA → #2C2C30/#ADADB3; chat
table violet glow #8B5CF6 → brand #7733F5; notification banner hover
#FFE4BF → orange/25 (≈ #FFE6BF); token devtool dot sky-400 → sky-500.
- `/marketplace` category chips: shadow tint #101828 → #141414 at 5–8%.

<details><summary>Before / after / diff images for the changed
pages</summary>

| Page (1440) | Before | After | Diff |
| --- | --- | --- | --- |
| `admin_memory` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__admin_memory__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__admin_memory__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__admin_memory__desktop.png"
width="260"> |
| `admin_diagnostics` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__admin_diagnostics__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__admin_diagnostics__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__admin_diagnostics__desktop.png"
width="260"> |
| `marketplace_skills` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace_skills__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace_skills__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace_skills__desktop.png"
width="260"> |
| `marketplace_search_searchTerm_lead` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace_search_searchTerm_lead__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace_search_searchTerm_lead__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace_search_searchTerm_lead__desktop.png"
width="260"> |
| `marketplace` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace__desktop.png"
width="260"> |
| `marketplace_agent_autogpt_business-ownerceo-finder` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="260"> |
| `build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="260"> |

| Crop | Before → after |
| --- | --- |
| `admin_memory__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__admin_memory__desktop.png"
width="520"> |
| `admin_diagnostics__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__admin_diagnostics__desktop.png"
width="520"> |
| `marketplace_skills__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__marketplace_skills__desktop.png"
width="520"> |
| `marketplace_agent_autogpt_business-ownerceo-finder__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="520"> |
| `build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="520"> |
| `admin_bots__mobile` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__admin_bots__mobile.png"
width="520"> |

</details>

### 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 lint` (design-system rules at error), `pnpm types`, `pnpm
format` clean
- [x] `pnpm test:unit`: 12,940 passed locally, all four CI shards green;
the 5 failures and 5 worker exits in that run were memory pressure on
the sandbox (OOM killer), and the four failing files pass on their own
(`stream-drift-content`, `stream-drift-guards`,
`creator-dashboard/main`, `GenericTool`); CI runs the full suite
- [x] `scripts/check-token-equivalence.ts --base <dev worktree>`: 123
pairs, 0 failing; 1,785 opacity swatches, 0 differing pixels
  - [x] `NEXT_PUBLIC_PW_TEST=true pnpm build` succeeds on the branch
- [x] Before/after screenshots of 43 routes at 1440x900 and 390x844 with
`scripts/screenshot-pages.ts` (dev 606cb1b vs this branch at 1ae437b,
same seeded database), compared at pixel threshold 0 (results above)
- [x] CI green on `1ae437b` (after merging `dev` with #16513): lint,
types, knip, the four unit-test shards, both end-to-end suites,
tooltip-browser and codecov all pass (unit shard 3 needed one rerun for
the `chat-surface-parity` "SSL destruction" teardown flake that `dev`
has too)

#### 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 outside `ds-colour-exempt.json`; colours use role tokens
- [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`; `className`
changes here are colour-token swaps only
- [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 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] I have included a list of my configuration changes in the PR
description (none)

🤖 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/208)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-04 02:53:08 +00:00
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]>
2026-11-04 01:07:03 +00:00
Abhimanyu YadavandClaude Opus 5.5 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` (441dc97) and of this branch,
on the same seeded backend and admin test user. The seed data:
  - 17 marketplace workflows;
  - 2 library workflows and a folder;
  - 5 uploaded files.
- Viewports 1440x900 and 390x844, full page, animations frozen. 36
pages, 72 pairs, pixel-diffed.
- Each pair was also compared as computed `font-size`/`line-height` per
visible text element. That comparison is what the list below is built
from.
- Pages:
  - login, signup, reset password, tour;
  - home (Otto), build, team, autopilot;
  - library, library workflow;
  - files (list, and grid with the thumbnail illustrations forced on);
  - marketplace (home, search, workflow, creator, skills);
- settings (profile, account, billing, integrations, API keys, memory,
creator dashboard), profile dashboard;
  - 11 admin pages.

**No visible change (34 pairs):**
- login, signup, reset password and tour: the hero moved from
`text-[4rem] leading-none` to `hero`, which is the same 64/64px. 29
anti-aliased pixels differ.
- home/Otto, build, library workflow, team, autopilot.
- every settings page and the profile dashboard.
- admin Users and Rate limits.

A few of these differ by under 50 anti-aliased pixels. Settings ›
Integrations (390) differs only because a provider logo loaded later in
one run.

**Small changes** (every text change is ≤4px; most are a 2px taller line
box):
- **Everywhere `text-sm`/`text-xs`/`text-base` was used:**
- line height +2px, size unchanged: 14/20 → 14/22, 12/16 → 12/18, 16/24
→ 16/26;
  - a column of such lines grows by 2px per line;
  - text with its own `leading-*` is unchanged.
- **Marketplace home:**
  - "Workflows" heading 30/36 → 28/40px;
  - section subtitles 18 → 20px;
  - card text +2px line;
  - at 390 the hero "Hire an AI expert for your team" goes 30 → 28px.
  - Page +6px (1440) / +2px (390).
- **Marketplace search, creator and workflow pages:** workflow card
titles 18 → 20px (same 28px line). "No runs" / run counts get a +2px
line.
- **Marketplace skills:**
  - "Skills" heading 30/36 → 28/40px;
  - subtitle 16/24 → 16/26;
  - "Go to your skills" link 14/20 → 14/22.
  - Page +8px at 390.
- **Library:**
  - filter/sort labels 14/20 → 14/22;
  - "Idle" status chips 12/16 → 12/18.
  - Page +2px (1440) / +3px (390).
- **Files:**
  - "All / Uploaded / Generated" chips 14/20 → 14/22.
- Grid thumbnails unchanged: the new SVG glyphs measure 3.00/6.00 ×
5/7.5px against the text's old 3.02/6.02 × 5/7.5px.
- **Admin pages:**
- page titles 30/36 → 28/40px: Block cost estimates, Bots, Diagnostics,
Execution analytics, Marketplace, Memory, Platform costs, Spending;
  - labels and body text +2px line; captions +2px line;
  - Diagnostics' stat numbers 30 → 28px;
  - Execution analytics' "Advanced options" 18 → 20px;
  - Platform costs' "(unsaved)" 10/16 → 10/18.
  - Dense pages grow from the added line height:
- Diagnostics +48px (1440) / +74 to 88px (390, depending on how many
items it lists);
    - Platform costs +34 / +48px;
    - Bots +16 / +24px;
    - Memory +8 / +26px.
- Admin › Marketplace also grows +24px, but from data: a "1 hour ago"
cell read "2 hours ago" and wrapped. Only its title changed from code.
- **Not on the pages above (from the mapping):**
- every remaining `text-lg` title (dialogs, sheets, cards) goes 18 →
20px on the same 28px line;
  - `text-3xl` headings 30/36 → 28/40;
- chat markdown inside Streamdown follows the same steps: h1 30 → 28px,
h4 18 → 20px, code/table/menus +2px line.

**Bigger visual changes**
- **Library (folders):** the folder card's emoji goes from 72px to 64px
(`text-7xl` → `hero`, the scale's largest step). The icon is drawn
scaled down, so on screen it goes from about 31×48px to 27×46px. It is
the only text-size change above 4px. Screenshots are in the PR comment.
- **Marketplace workflow page, 1440:** the "Build AI workflows and share
your vision" banner heading goes 36 → 32px. The size change itself is
within 4px, but the heading now fits on one line instead of two, so
everything below it moves up 40px. At 390 it goes 30 → 28px and still
wraps the same way. Screenshots are in the PR comment.


### 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 lint` (design-system rules at error), `pnpm types`, `pnpm
format`
- [x] `pnpm test:unit`: 1,177 files, 13,024 tests passed. Locally vitest
also reported 3 unhandled `ECANCELED`/`ECONNREFUSED` socket errors from
the desktop-preview iframe tests (`chat-surface-parity`,
`computerFace`). The same errors happen on untouched `dev` in this
sandbox, which has no outbound network for those iframes.
- [x] `type-scale`, `design-system`, `tailwind3-compat`, `tw-merge`
script/unit tests
- [x] Before/after Playwright screenshots, 36 pages × 1440x900 and
390x844, pixel-diffed (see above)

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; no
baseline count was raised (the new rule's baseline is 0)
- [x] No `dark:`/`light:` classes, raw palette classes or hex/`rgb()`
colours added; no colour tokens renamed or removed (PR A's job)
- [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` where it did;
the size swaps keep each element as it was (see How)
- [x] Before/after screenshots at 1440 and 390 px were taken and diffed;
every visible change is listed above
- [x] User-facing copy unchanged

#### 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**): none

🤖 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/210)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-03 12:28:21 +00:00
Abhimanyu YadavandClaude Opus 5.5 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` (e681d74) and this
branch, on the same seeded backend and test user.
- Viewports 1440x900 and 390x844, full page, 33 pages, 66 pairs,
pixel-diffed.
- Pages: login, signup, home, copilot, library, library agent,
followups, skills, build (empty and with a workflow), marketplace (home,
search, skills, workflow, creator), team, autopilot, artifacts, raise,
all 11 settings pages, tour, tour chat, reset password and setup wizard.

**Result: no code-caused pixel change on any of the 66 pairs.** 58 are
identical. 8 differ only by data or animation:
- tied marketplace cards in a different order;
- "1h ago" vs "2h ago";
- an animated avatar frame;
- texture noise on raise.

The changes below are in components those pages don't render at rest:
dialogs, builder nodes, hover and focus states. Each is within the
owner's "small" rule.

**Small changes** (text shifts ≤4px, slight shade shifts, hover- or
motion-only)
- **Build:**
- Draft recovery "Discard" goes from 34px to 36px, the same as
"Restore".
  - Node output copy buttons: 30px → 32px. "View data": 30/34px → 32px.
  - Data viewer copy/download buttons: 26px → 28px.
  - Filter sheet close and edge remove (X): 22px → 24px.
  - Filter sheet "Clear"/"Apply": side padding 8px → 12px.
  - Text input expander copy: 34px → 32px.
- Simulate and "Update Anyway": the hover border is amber instead of
grey/dark.
  - Copy buttons: hover text stays foreground-base instead of strong.
- **Copilot:**
  - "Hire and start": side padding 14px → 12px.
  - Connection chip: side padding 10px → 12px.
  - Tier glyph: hover fill surface-hover → surface-muted.
- File download/delete, integrations header icons, "View usage" and
"Show all items": hover text now darkens to foreground-body.
  - "Upgrade to Max" fill: brand-hover #6C2EDF → brand #7733F5.
- **Raise:** "Back"/"Start over" go from 34x36 to 36x36, and their hover
fill from surface-hover to surface-muted.
- **Home:** tile filter gap 4px → 6px, side padding 10px → 12px, hover
fill surface-hover → surface-muted.
- **Paywall:**
  - The inactive Monthly/Yearly segment's text darkens on hover.
  - "Log out" drops a 90% white fill that sat on the white dialog.
- **OAuth authorize and integrations setup wizard:** the two button
labels go from 18px/28px to 15px/24px (`textSize="body-md"`). Button
height is unchanged.
- **Login/signup:** gap between the Google logo and its label 12px →
8px.
- **Library:**
  - Move-to-folder rows: left padding 12px → 16px, gap 12px → 8px.
  - "Simulate" (run modal): gap 6px → 8px.
- **Marketplace skills:** the install/actions dropdown arrow's side
padding goes from 10px → 12px.
- **Settings/memory and team notes:** "Forget" side padding 8px → 12px.
- **Components:**
  - File-tree collapse button: 24x32 → 32x32.
  - Changelog dismiss: hover text goes from base to body.
- Wallet usage "Retry": height 26px → 28px, padding 8px → 10px, min
width 88px → 80px.
- Pending reviews "Reject": resting fill red-600 → red-500, the
destructive variant's own fill.
  - Ayrshare connect: gap 8px → 6px.
- Cron scheduler "Select all"/"Weekdays"/"Weekends": side padding 8px →
12px.
  - Plan offer link: the loading-spinner gap goes from 6px → 4px.
- Activity search toggle and two builder toggles: only the focus-ring
corner radius changes.
- Builder filter chip: only if the pointer is already over a selected
chip when it mounts, the label shows white until the mouse moves.
- **Motion only:**
  - Spinners using `spin` stop for users with reduced motion turned on.
- Icon transitions using `motion` run at 200ms; some were 150ms or
300ms.

**Bigger visual changes:** none.

### Agents and large language models used

- Claude Code with Claude Opus 5.5 (claude-opus-5-5), plus Claude Code
subagents on the same model for the per-directory call-site passes.

### 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` (design-system rules at error), `pnpm types`, `pnpm
format`
  - [x] `pnpm vitest run`: 1,174 files, 12,987 tests passed
  - [x] Unit tests for every new Text, Icon and Button prop
- [x] Before/after Playwright screenshots, 33 pages x 2 viewports,
pixel-diffed (see above)

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules. No
baseline count went up except:
  - the files newly checked by the tightened `no-restyle`;
- `styles/tones.ts`, which took over the emerald-300 entry from
`Text/helpers.ts`.
- [x] No `dark:`/`light:` classes, raw palette classes or hex/`rgb()`
colours added. The new variants use role tokens.
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`
- [x] Before/after screenshots at 1440 and 390 px. No code-caused pixel
change on the audited pages; the method is described above.
- [x] User-facing copy unchanged

🤖 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/209)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 04:17:46 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-11-02 16:24:19 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-11-02 14:59:19 +00:00
posthog-eu[bot] 1b4bade6ad fix(frontend): reload once when a chunk fails to load after a deploy (#16312) 2026-10-30 18:20:13 +09:00
Abhimanyu YadavandClaude Opus 5.5 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]>
2026-10-30 04:50:56 +00:00
Abhimanyu YadavandClaude Opus 5.5 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 acfdad9b6 → this branch)

| Check | Before | After |
|---|--:|--:|
| `git grep -o 'dark:' src` | 52 | 5 (not classes: Shiki `themes: {
dark: … }` keys ×4, one test comment) |
| `dark:` classes (`design-system/no-dark-variant`) | 46 (all in
`__legacy__` + `legacyButtonVariants`) | **0** |
| `variant="legacy-` | 26 | **0** |
| `__legacy__` importers | 0 | 0 (folder deleted) |
| `git grep -c 'TODO(tokens)'` | 93 | 91 |
| bare shadcn colour classes | 496 | 487 |
| design-system violations ignoring the baseline
(`DESIGN_SYSTEM_BASELINE=off oxlint src`; `dev` has no
`.oxlintrc.strict.json`) | 2,265 | 2,105 |
| `pnpm lint` | pass | pass |

### Left at baseline (grandfathered, still failing on any increase)

These are not zero. Fixing them needs visible changes or is larger than
this PR, so they stay in `design-system-baseline.json`:
- `no-restyle` 1,956: copilot 827, build 424, contextual 219, layout
173, home 71, renderers 71, raise 50, auth 41.
- `no-inline-styles` 57: dynamic widths and animations in copilot and
renderers.
- `require-static-classes` 42: 22 in `ChatInput`.
- `hugeicons-only` 41: P11.
- `no-unknown-classes` 7: `disabled:opacity-1` in
`extendedButtonVariants`. It generates no CSS, but `tailwind-merge` uses
it to drop `disabled:opacity-50`, so disabled buttons render opaque.
Removing it dims every disabled button. Swapping it for
`disabled:opacity-100` would override callers that dim disabled buttons
with `opacity-40` (`RecordingButton`, `ComposerPlusMenu` and others).
- `no-raw-colors` 1: `text-emerald-300` in the Text `success-light` tone
(5.7 item 11, no role).
- `autogpt/no-hookless-use-function` stays `warn`, with 6 hits. It is a
React Compiler rule, not a design-system one.

**Not deleted, because call sites remain** ("never delete a recipe/alias
while a call site remains"):
- `styles/colors.ts` and its spread in `tailwind.config.ts`. Hundreds of
raw `orange-*`/`yellow-*`/`pink-*`/`slate-*` classes still read it:
admin diagnostics, platform costs, the chart helpers and others.
Removing it would repaint them with Tailwind's default palette.
`@shadcn/lint` treats these colours as theme colours, so `no-raw-colors`
does not count them.
- The 27 shadcn alias lines in `globals.css`. There are 487 bare uses,
e.g. `text-muted-foreground` ×153, `text-foreground` ×46,
`bg-background` ×31. The aliases are exact, so renaming them is
zero-pixel. It is a mechanical follow-up that touches about 200 files,
kept out of this PR.
- `TODO(tokens)` 91. Each marks a value with no role yet: no type role
for 18 px or 24 px, `text-sm` with `leading-5` in the Text variants,
chart and SVG colours where `var()` does not resolve. Clearing them
means new roles or snapping sizes, which is SECRT-3152's decision. This
PR removes only the two it resolves: the LegacyButton note and
`global-error`'s `bg-gray-50`.

### Visible change

Within the owner's limit (slightly different greys and button colours).
Labelled `pixel-change` because the dark button fill is noticeable side
by side. **No geometry changes:** every changed pixel sits inside an
existing button, and every button keeps its position, size, radius,
padding and text metrics. I checked this by pixel-diffing before/after
renders of the real components (`CronScheduler`, `app/error.tsx`) and of
the dialog footers, with the same `variant` props the call sites pass,
at `origin/dev` and on this branch.

| Screen | What changes | Old → new |
|---|---|---|
| Admin › Marketplace, Approve dialog: **Approve** | dark fill | #171717
→ `action` #3E3E43 (hover #171717/90 → #2C2C30); text #FAFAFA → #FEFEFE
|
| Admin › Marketplace, Approve/Reject dialogs: **Cancel**; admin search
button | outline border, hover | border #E5E5E5 → #DADADC; hover fill
#F5F5F5 → #EFEFF0 |
| Admin › Marketplace, Reject dialog: **Reject / Revoke** | nothing |
#EF4444, unchanged |
| Admin › Spending, Add Dollars dialog | as Approve and Cancel above |
same shades |
| Builder › MCP tool dialog: **Discover Tools / Sign in / Add Block**,
**Back**, **Cancel** | as above | same shades |
| Schedule (cron) dialog: **Done**, **Cancel**; selected weekday,
day-of-month and month chips; All Days / Customize / Select All toggles
| as above | same shades |
| App error page (`app/error.tsx`): **Go to Homepage**, **Retry** | as
above | same shades |
| `(platform)/error.tsx` and `global-error.tsx` page background | grey |
#F9FAFB → #F9F9FA (1 step in two channels; not visible) |
| All of the buttons above, keyboard focus only | focus ring | none → 1
px `focus` #0A0A0A ring |

Nothing changes size or position, and nothing changes by more than a
shade of grey/dark-grey. The pixel diff per render, as changed pixels
inside these bounds: dialog footers 15,301 px in the button column, cron
monthly 10,683, cron weekly 5,612, cron yearly 4,176, error page 5,064
(the two buttons only).

Before (left, `dev`) / after (right, this PR):

**Dialog footers** (admin approve/reject, Add Dollars, MCP dialog,
disabled state)
![dialog
footers](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/dade545d28c657ad4ed8bfa00338463c9920d8c7/p10/cmp-dialog-footers-1440.png)

**App error page** (1440 and 390)
![error page
1440](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/dade545d28c657ad4ed8bfa00338463c9920d8c7/p10/cmp-root-error-page-1440.png)
![error page
390](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/dade545d28c657ad4ed8bfa00338463c9920d8c7/p10/cmp-root-error-page-390.png)

**Schedule dialog, weekly / monthly / yearly**
![cron
weekly](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/dade545d28c657ad4ed8bfa00338463c9920d8c7/p10/cmp-cron-weekly-1440.png)
![cron
monthly](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/dade545d28c657ad4ed8bfa00338463c9920d8c7/p10/cmp-cron-monthly-1440.png)
![cron
yearly](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/dade545d28c657ad4ed8bfa00338463c9920d8c7/p10/cmp-cron-yearly-1440.png)

The dialog and cron renders sit in a fixed 640 px panel, so the 390 px
captures are identical to the 1440 ones. They are in `p10/` on the
`abhi/ds-p10-screenshots` branch.

The non-colour class differences were checked by resolving both recipes
through `tailwind-merge`. `font-sans` is the font the body already
inherits. `gap-2` has no effect because every migrated button has a
single child. `[&_svg]:size-4` matches the one icon's existing `h-4
w-4`. `min-w-0` and `border-0` restate the defaults.

**Deferred visual changes:** none were needed. The pill shape for these
buttons (5.7 item 2) stays deferred.

### 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 format`, `pnpm lint` (ESLint, oxlint with every
`design-system/*` rule at error, Prettier), `pnpm types`
  - [x] `pnpm test:unit`: 1,138 files, 12,450 passed, 4 expected fail
- [x] Button tests rewritten for the compact family: children rendered
as-is, rounded-md, size, ref and `data-slot`
- [x] Class-level diff of each `legacy-*` recipe against its `compact-*`
replacement (table above)

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; no
baseline count raised (the baseline only shrank: 405 → 392 files)
- [x] No `dark:`/`light:` classes, raw palette classes or hex colours
added
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
(all deleted)
- [x] Primitives get a `variant`/`size`
- [x] Pixels: the colour-only differences are listed above, with no
shape, size or layout change
- [x] No user-facing copy changed

🤖 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/200)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-30 03:11:54 +00:00
Nicholas TindleandClaude Opus 5.5 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]>
2026-10-30 00:42:57 +00:00
Abhimanyu YadavandClaude Opus 5.5 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]>
2026-10-29 11:49:02 +00:00
Nicholas TindleandClaude Opus 5.5 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]>
2026-10-27 23:13:28 +00:00
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


![d01-rest-founder-1440x900](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d01-rest-founder-1440x900.jpg?raw=true)

![d02-rest-operations-1280x720](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d02-rest-operations-1280x720.jpg?raw=true)

![d03-rest-sales-reduced-motion-1366x768](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d03-rest-sales-reduced-motion-1366x768.jpg?raw=true)

Desktop, typing: Operations 1280x720, Marketing 1440x900


![d04-typing-operations-1280x720](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d04-typing-operations-1280x720.jpg?raw=true)

![d05-typing-marketing-1440x900](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d05-typing-marketing-1440x900.jpg?raw=true)

Desktop, recording: Founder/CEO 1440x900, Operations 1280x720


![d06-recording-founder-1440x900](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d06-recording-founder-1440x900.jpg?raw=true)

![d07-recording-operations-1280x720](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d07-recording-operations-1280x720.jpg?raw=true)

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)


![d08-mic-blocked-1280x720](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d08-mic-blocked-1280x720.jpg?raw=true)

![d09-mic-allowed-in-site-settings-1280x720](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d09-mic-allowed-in-site-settings-1280x720.jpg?raw=true)

Prompt closed instead of blocked, 1366x768


![d10-mic-prompt-closed-1366x768](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/d10-mic-prompt-closed-1366x768.jpg?raw=true)

Phone 390x844: rest (Founder/CEO), typing, recording, blocked microphone
(Operations)


![p-390x844-rest-typing-recording-blocked](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/p-390x844-rest-typing-recording-blocked.jpg?raw=true)

Phone 320x640: rest (Operations), blocked microphone (Founder/CEO)


![p-320x640-rest-blocked](https://github.com/Significant-Gravitas/AutoGPT/blob/4cdcde3d9876c104d93764786280392c73124e4b/autogpt_platform/frontend/.pr-screenshots/secrt-3198/p-320x640-rest-blocked.jpg?raw=true)

#### 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 (68cedd65a6, 40dc79a557, d6abdc63d6)

- The prompt is announced only after "Another question"; nothing that
changes on its own is read out. It keeps changing while recording (every
15 s, c89929254a), and a click on an idea, or the keyboard toggle,
pauses every changing idea (Abhi's blocker, WCAG 2.2.2).
- A microphone prompt answered after the typed text was sent starts no
take.
- A refusal while the site is allowed (the OS blocking the browser)
points to the computer's privacy settings; a retry that finds the mic
missing or busy says so on the typing screen.
- On wide screens the rest-screen question stays in the accessibility
tree (`xl:sr-only`).
- A mic retry refused or allowed while the typed text is being sent
leaves the submission alone (no reopened composer, no second finalize,
no take); a retry after an unavailable mic keeps that notice.
- `AmbientCard` extracted; the cards don't drift where the layer is
hidden.
- `brain_dump_permission_retry` → `brain_dump_permission_retried`,
`trigger` → `via`; tracking plan updated. `brain_dump_permission_denied`
and `brain_dump_mic_failed` are sent again on each refused retry,
documented there.

### Agents and large language models used

- Claude Code with Claude Opus 5.5
- Review rounds: Claude Code with Claude Fable 5.1, and Codex with GPT-6
Astra (read-only, findings acted on in the follow-up commits)

### 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` (no new warnings; the two in
`useBrainDumpStep.ts` were already there), `pnpm types`: clean
- [x] `pnpm test:unit`: 1112 files, 11,990 tests passed (plus the
suite's 4 expected failures)
- [x] New unit tests: `prompts.test.ts`, `usePromptRotation.test.ts`,
`useMicrophonePermission.test.ts`,
`components/__tests__/AmbientPrompts.test.tsx`, `OttoMightAsk.test.tsx`,
`MicBlockedNotice.test.tsx`; extended `useBrainDumpStep.test.ts` (prompt
properties on completed/skipped/typed_fallback, retry
granted/denied/unavailable, once-per-visit fallback, permission change
to granted and to prompt), `useBrainDumpRecorder.test.ts` (`onMicAnswer`
before `brain_dump_started`, retry after a cleared refusal) and the
page-level `__tests__/brain-dump.test.tsx` (prompts and the whole
refused-microphone flow end to end)
- [x] Deliberately updated existing assertions: the rest guidance copy,
`DURATION_GUIDANCE_COPY`, "record instead" now present when the mic is
refused, and the exact-payload assertions on `brain_dump_completed`
(typed, voice) and `brain_dump_typed_fallback` (both reasons), which now
also carry the new properties. Nothing else changed in existing tests
- [x] Layout, measured in Chromium against the real page: rest, typing
and recording at 1440x900, 1366x768 and 1280x720 for Founder/CEO and
Operations, and again with reduced motion: no vertical or horizontal
scroll, so everything is above the fold (at rest: headline, orb, Talk,
Write and the privacy line, the SECRT-2832 check), and no card
overlapping Back, Skip, the dots, Log out or any control. Also clean at
1920x1080, 1024x768 and 800x1000
- [x] Phones (Chromium device emulation): no horizontal scroll at
390x844 or 320x640. Same as `dev`: rest and recording fit at 390x844,
and typing already scrolled at 320x640 (725px there, 813px now). New:
typing at 390x844 scrolls 49px (dots and Log out only), and rest at
320x640 scrolls 54px (dots and Log out only; Talk, Write and the privacy
line stay visible)
- [x] Blocked microphone at 1280x720: "I'm done" and "record instead"
stay above the fold with the notice up; the privacy line, step dots and
Log out move 71px below it (with the shorter closed-prompt copy: 49px,
dots and Log out only)
- [x] Keyboard: DOM/tab order is Back, Skip for now, Talk, Write at
rest; Back, Skip for now, Another question, textarea, I'm done (once
enabled), record instead while typing; Back, Another question, Cancel,
Send, Retry while recording (Skip hidden, as before). Nothing in the
ambient layer is focusable
- [x] Blocked microphone in Chromium: Block → notice + "record instead"
still there; retry while blocked → same single notice, no second
`typed_fallback`; allowing the site → notice clears without a reload and
Talk records. Closed prompt → closed-prompt copy; retry answered Allow →
recording. Repeated at 390x844 and 320x640, and with
`prefers-color-scheme: dark` (the app forces the light theme, so it
renders the same)
- [x] The Playwright onboarding helper's `getByRole("button", { name:
"Skip for now", exact: true })` still resolves to the single Skip button
(the ambient layer has no buttons; its cards take clicks only to pause
or resume the ideas, and the keyboard toggle is named "Pause changing
ideas")
- [ ] Not done here, please check on the preview: a real screen reader
(VoiceOver/NVDA; checked here only through the accessibility tree: the
ambient cards are absent, the live region reads "Otto might ask: …"
after "Another question", the notice is `role="alert"`),
`posthog.debug()` against a real project, and a real iOS Safari /
Android Chrome device. Headless Chromium cannot show the permission
prompt, so "Block" was a permission set to denied, "Allow" a granted
permission, and "closed" Chrome's `NotAllowedError: Permission
dismissed` with the permission left at `prompt`

#### 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**): none

---
_Generated by [Claude
Code](https://claude.ai/code/session_01SbTXYHsM8RdABFDRoqHKjY)_

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Krzysztof Czerwinski <[email protected]>
Co-authored-by: Toran Bruce Richards <[email protected]>
2026-10-27 12:33:14 +00:00
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)

![Step 1:
chats](https://github.com/user-attachments/assets/6c60915a-2a18-4138-994c-d36482a8e4ff)
![Step 2:
Team](https://github.com/user-attachments/assets/b4611ccd-505d-47bd-b72d-e55c140dfca9)
![Step 3:
Files](https://github.com/user-attachments/assets/1cbeb318-6135-480c-843f-9db5b01d8f39)
![Step 4:
allowance](https://github.com/user-attachments/assets/80fc8920-76e4-49b0-b4a1-523ecbf34337)
![Step 5: start
here](https://github.com/user-attachments/assets/5c37f605-4568-4fe5-837b-dad5fa51c4e6)

**One-time tip** (collapsed sidebar, after a second chat appears)

![Tip: two
chats](https://github.com/user-attachments/assets/69be7caa-4e0a-4b93-a3e9-420c93dbce39)

### 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]>
2026-10-27 11:11:29 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-10-27 06:15:52 +00:00
Nicholas TindleandClaude Opus 5.5 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]>
2026-10-27 01:10:03 +00:00
Nicholas TindleandClaude Opus 5.5 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]>
2026-10-27 01:04:13 +00:00
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]>
2026-10-26 00:14:28 +00:00
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]>
2026-10-25 13:07:39 +00:00
Reinier van der LeerandClaude Opus 5.5 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]>
2026-10-24 11:30:58 +00:00
Reinier van der LeerandClaude Opus 5.5 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]>
2026-10-24 10:48:42 +00:00
Reinier van der LeerandClaude Opus 5.5 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]>
2026-10-23 08:38:26 +00:00
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>

![payment_failed before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/payment_failed-390-normal.png)

</details>

<details>
<summary>payment_final_notice: 390px before / after</summary>

![payment_final_notice before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/payment_final_notice-390-normal.png)

</details>

<details>
<summary>briefing-weekly: 390px before / after</summary>

![briefing-weekly before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-weekly-390-normal.png)

</details>

<details>
<summary>cause-low-balance: 390px before / after</summary>

![cause-low-balance before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-low-balance-390-normal.png)

</details>

<details>
<summary>cause-zero-balance: 390px before / after</summary>

![cause-zero-balance before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-zero-balance-390-normal.png)

</details>

<details>
<summary>trial-started: 390px before / after</summary>

![trial-started before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-started-390-normal.png)

</details>

<details>
<summary>trial-converted: 390px before / after</summary>

![trial-converted before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-converted-390-normal.png)

</details>

<details>
<summary>trial-ended: 390px before / after</summary>

![trial-ended before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-ended-390-normal.png)

</details>

<details>
<summary>All other changed variants: complete 390px comparisons</summary>

**alert-0**

![alert-0 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/alert-0-390-normal.png)

**alert-3**

![alert-3 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/alert-3-390-normal.png)

**auth-change_email**

![auth-change_email before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/auth-change_email-390-normal.png)

**auth-reset_password**

![auth-reset_password before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/auth-reset_password-390-normal.png)

**auth-set_password**

![auth-set_password before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/auth-set_password-390-normal.png)

**auth-verify_email**

![auth-verify_email before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/auth-verify_email-390-normal.png)

**briefing-attention-overflow**

![briefing-attention-overflow before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-attention-overflow-390-normal.png)

**briefing-daily**

![briefing-daily before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-daily-390-normal.png)

**briefing-monthly**

![briefing-monthly before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-monthly-390-normal.png)

**briefing-quiet**

![briefing-quiet before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-quiet-390-normal.png)

**briefing-rough**

![briefing-rough before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-rough-390-normal.png)

**cause-auth-expired**

![cause-auth-expired before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-auth-expired-390-normal.png)

**cause-awaiting-input**

![cause-awaiting-input before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-awaiting-input-390-normal.png)

**cause-awaiting-review**

![cause-awaiting-review before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-awaiting-review-390-normal.png)

**cause-block-failed**

![cause-block-failed before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-block-failed-390-normal.png)

**cause-continuous-error**

![cause-continuous-error before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-continuous-error-390-normal.png)

**cause-guardrail**

![cause-guardrail before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-guardrail-390-normal.png)

**cause-low-balance-no-forecast**

![cause-low-balance-no-forecast before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-low-balance-no-forecast-390-normal.png)

**cause-paused-failures**

![cause-paused-failures before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-paused-failures-390-normal.png)

**ended-payment-False**

![ended-payment-False before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/ended-payment-False-390-normal.png)

**ended-payment-True**

![ended-payment-True before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/ended-payment-True-390-normal.png)

**ops-processed**

![ops-processed before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/ops-processed-390-normal.png)

**ops-request**

![ops-request before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/ops-request-390-normal.png)

**reference-changelog**

![reference-changelog before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-changelog-390-normal.png)

**reference-tour-1**

![reference-tour-1 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-1-390-normal.png)

**reference-tour-2**

![reference-tour-2 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-2-390-normal.png)

**reference-tour-3**

![reference-tour-3 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-3-390-normal.png)

**reference-tour-4**

![reference-tour-4 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-4-390-normal.png)

**reference-tour-5**

![reference-tour-5 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-5-390-normal.png)

**reference-tour-6**

![reference-tour-6 before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-6-390-normal.png)

**subscription_cancelled**

![subscription_cancelled before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/subscription_cancelled-390-normal.png)

**subscription_resumed**

![subscription_resumed before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/subscription_resumed-390-normal.png)

**subscription_welcome**

![subscription_welcome before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/subscription_welcome-390-normal.png)

**trial-canceled**

![trial-canceled before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-canceled-390-normal.png)

**trial-ending**

![trial-ending before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-ending-390-normal.png)

**trial-payment_failed**

![trial-payment_failed before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-payment_failed-390-normal.png)

**trial-resumed**

![trial-resumed before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-resumed-390-normal.png)

**verdict-approved**

![verdict-approved before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/verdict-approved-390-normal.png)

**verdict-changes**

![verdict-changes before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/verdict-changes-390-normal.png)

**verdict-no-feedback**

![verdict-no-feedback before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/verdict-no-feedback-390-normal.png)

**welcome-experts-enabled**

![welcome-experts-enabled before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/welcome-experts-enabled-390-normal.png)

</details>

<details>
<summary>Desktop and style-stripped comparisons (12 representative variants)</summary>

**auth-verify_email-390-style-off**

![auth-verify_email-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/auth-verify_email-390-style-off.png)

**auth-verify_email-640-normal**

![auth-verify_email-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/auth-verify_email-640-normal.png)

**briefing-weekly-390-style-off**

![briefing-weekly-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-weekly-390-style-off.png)

**briefing-weekly-640-normal**

![briefing-weekly-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/briefing-weekly-640-normal.png)

**cause-low-balance-390-style-off**

![cause-low-balance-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-low-balance-390-style-off.png)

**cause-low-balance-640-normal**

![cause-low-balance-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-low-balance-640-normal.png)

**cause-zero-balance-390-style-off**

![cause-zero-balance-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-zero-balance-390-style-off.png)

**cause-zero-balance-640-normal**

![cause-zero-balance-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/cause-zero-balance-640-normal.png)

**ops-request-390-style-off**

![ops-request-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/ops-request-390-style-off.png)

**ops-request-640-normal**

![ops-request-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/ops-request-640-normal.png)

**payment_failed-390-style-off**

![payment_failed-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/payment_failed-390-style-off.png)

**payment_failed-640-normal**

![payment_failed-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/payment_failed-640-normal.png)

**payment_final_notice-390-style-off**

![payment_final_notice-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/payment_final_notice-390-style-off.png)

**payment_final_notice-640-normal**

![payment_final_notice-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/payment_final_notice-640-normal.png)

**reference-changelog-390-style-off**

![reference-changelog-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-changelog-390-style-off.png)

**reference-changelog-640-normal**

![reference-changelog-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-changelog-640-normal.png)

**reference-tour-1-390-style-off**

![reference-tour-1-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-1-390-style-off.png)

**reference-tour-1-640-normal**

![reference-tour-1-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/reference-tour-1-640-normal.png)

**subscription_welcome-390-style-off**

![subscription_welcome-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/subscription_welcome-390-style-off.png)

**subscription_welcome-640-normal**

![subscription_welcome-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/subscription_welcome-640-normal.png)

**trial-converted-390-style-off**

![trial-converted-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-converted-390-style-off.png)

**trial-converted-640-normal**

![trial-converted-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-converted-640-normal.png)

**trial-started-390-style-off**

![trial-started-390-style-off before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-started-390-style-off.png)

**trial-started-640-normal**

![trial-started-640-normal before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/trial-started-640-normal.png)

</details>

<details>
<summary>greeting-fallback: actual conditional lifecycle payload before / after</summary>

![greeting-fallback before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/greeting-fallback-390-normal.png)

</details>

<details>
<summary>payment-no-retry-date: actual conditional lifecycle payload before / after</summary>

![payment-no-retry-date before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/payment-no-retry-date-390-normal.png)

</details>

<details>
<summary>final-hosted-invoice: actual conditional lifecycle payload before / after</summary>

![final-hosted-invoice before and after](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/ce91ef30e/docs/engineering/email-review/final-hosted-invoice-390-normal.png)

</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]>
2026-10-22 18:18:36 -05:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-10-22 12:11:50 +00:00
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]>
2026-10-22 05:19:04 +00:00
Reinier van der LeerandClaude Opus 5.5 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]>
2026-10-22 00:04:13 +00:00
Reinier van der LeerandClaude Opus 5.5 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]>
2026-10-21 19:15:59 +00:00
Nicholas Tindleandgpt-6-astra 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]>
2026-10-21 17:08:56 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-10-21 14:51:40 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-10-21 13:59:20 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-10-21 13:59:11 +00:00
Nicholas TindleandClaude Opus 5.5 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]>
2026-10-21 08:02:12 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-10-20 10:23:40 +00:00
Abhimanyu YadavandClaude Opus 5.5 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 | ![2000-message chat opened on
dev](https://github.com/user-attachments/assets/da11f2d7-5e4a-4d72-b5f1-c103278f4dab)
| ![2000-message chat opened with the windowed
transcript](https://github.com/user-attachments/assets/e1df89de-424a-403e-b57d-10f2cb4a53ef)
|
| After loading 4 older pages from the real backend (the reader was on
Turn 85–86) | ![dev lost the reading position after loading older
pages](https://github.com/user-attachments/assets/d9fa9507-7063-44c4-9aca-dc4315e774ec)
| ![the windowed transcript kept the reader on Turn 85 to
86](https://github.com/user-attachments/assets/ecd2e3d1-2bff-4837-9a05-4a008823d467)
|
| Minimap jump to Turn 1 | ![minimap jump to turn 1 on
dev](https://github.com/user-attachments/assets/1e7f487a-c9a5-412c-8c42-3834921d3127)
| ![minimap jump to turn 1 with the windowed
transcript](https://github.com/user-attachments/assets/83dd5716-6ed4-4f37-a365-0c54c2507dc9)
|
| Minimap jump to Turn 5, then "Used tools" expanded | ![tool row
expanded on
dev](https://github.com/user-attachments/assets/2b2b374c-3f68-4789-9667-1a5a109ed846)
| ![tool row expanded with the windowed
transcript](https://github.com/user-attachments/assets/6fbcea77-81fa-4df2-ad59-1e4bff1d6ef6)
|
| After a streamed reply, pinned | ![after a streamed reply on
dev](https://github.com/user-attachments/assets/6178ed08-d7a2-4765-9431-ed33e76eec5d)
| ![after a streamed reply with the windowed
transcript](https://github.com/user-attachments/assets/b7706421-3cca-44da-9f26-ee2b6e876f56)
|

#### 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]>
2026-10-20 05:11:23 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 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]>
2026-10-20 03:34:08 +00:00
Abhimanyu YadavandClaude Opus 5.5 1M context 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 | 8eb406275 | 203 | 34,075 | 167.9 | 2,087 | 21 | 91.8 |
| before | 2 | 8eb406275 | 208 | 34,124 | 164.1 | 1,946 | 20 | 90.2 |
| before | 3 | 8eb406275 | 209 | 34,143 | 163.4 | 2,134 | 20 | 91.7 |
| after | 1 | 8eb406275 | 203 | 33,595 | 165.5 | 2,005 | 16 | 91.6 |
| after | 2 | 8eb406275 | 208 | 33,639 | 161.7 | 1,738 | 5 | 89.8 |
| after | 3 | 8eb406275 | 205 | 33,621 | 164.0 | 1,731 | 12 | 89.2 |

</details>


</details>
### builder-type-field — median of 3 (before) vs 3 (after)

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 120 | 120 | 0 (0.0%) |
| total renders | 9,680 | 9,520 | -160 (-1.7%) |
| renders per commit | 80.7 | 79.3 | -1.3 (-1.7%) |
| max renders in one commit | 207 | 207 | 0 (0.0%) |
| script time (ms) | 826 | 631 | -196 (-23.7%) |
| task time (ms) | 1,013 | 783 | -230 (-22.7%) |
| layout time (ms) | 16 | 14 | -3 (-15.6%) |
| style recalc time (ms) | 2 | 1 | -0 (-14.5%) |
| long tasks (≥ 50 ms) | 4 | 0 | -4 (-100.0%) |
| long task total (ms) | 257 | 0 | -257 (-100.0%) |
| heap used after GC (MB) | 82.1 | 81.4 | -0.6 (-0.8%) |

<details><summary>Top components, causes and raw runs</summary>

**Top components** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `Primitive.div` | 440 | 440 | 0 (0.0%) |
| `Tooltip` | 360 | 360 | 0 (0.0%) |
| `TooltipContent` | 360 | 360 | 0 (0.0%) |
| `TooltipProvider` | 340 | 340 | 0 (0.0%) |
| `Presence` | 320 | 320 | 0 (0.0%) |
| `HugeiconsIcon` | 300 | 280 | -20 (-6.7%) |
| `Icon` | 300 | 280 | -20 (-6.7%) |
| `SelectItem` | 280 | 280 | 0 (0.0%) |
| `Popper` | 260 | 260 | 0 (0.0%) |
| `PopperAnchor` | 260 | 260 | 0 (0.0%) |

**Top render causes** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `Tooltip props:children` | 360 | 360 | 0 (0.0%) |
| `TooltipProvider props:children` | 340 | 340 | 0 (0.0%) |
| `Presence props:children` | 320 | 320 | 0 (0.0%) |
| `HugeiconsIcon parent-same-props<-Icon` | 280 | 260 | -20 (-7.1%) |
| `SelectItem props:onMouseDown,children` | 280 | 280 | 0 (0.0%) |
| `Primitive.div props:children` | 260 | 260 | 0 (0.0%) |
| `Primitive.div.Slot props:children` | 260 | 260 | 0 (0.0%) |
| `Primitive.div.SlotClone props:children` | 260 | 260 | 0 (0.0%) |
| `Popper props:children` | 220 | 220 | 0 (0.0%) |
| `PopperAnchor props:children` | 220 | 220 | 0 (0.0%) |

<details><summary>Raw runs</summary>

| label | run | commit | commits | renders | renders/commit | script ms
| long tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before | 1 | 8eb406275 | 120 | 9,680 | 80.7 | 702 | 0 | 82.1 |
| before | 2 | 8eb406275 | 120 | 9,680 | 80.7 | 826 | 4 | 82.0 |
| before | 3 | 8eb406275 | 120 | 9,680 | 80.7 | 929 | 7 | 82.1 |
| after | 1 | 8eb406275 | 120 | 9,520 | 79.3 | 686 | 0 | 82.0 |
| after | 2 | 8eb406275 | 120 | 9,520 | 79.3 | 631 | 0 | 81.4 |
| after | 3 | 8eb406275 | 120 | 9,520 | 79.3 | 605 | 0 | 81.4 |

</details>


</details>
### builder-block-menu-search — median of 3 (before) vs 3 (after)

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 64 | 64 | 0 (0.0%) |
| total renders | 1,843 | 924 | -919 (-49.9%) |
| renders per commit | 28.8 | 14.4 | -14.4 (-49.9%) |
| max renders in one commit | 211 | 50 | -161 (-76.3%) |
| script time (ms) | 268 | 133 | -135 (-50.5%) |
| task time (ms) | 937 | 680 | -257 (-27.5%) |
| layout time (ms) | 11 | 10 | -1 (-6.0%) |
| style recalc time (ms) | 41 | 35 | -6 (-15.4%) |
| long tasks (≥ 50 ms) | 0 | 0 | 0 |
| long task total (ms) | 0 | 0 | 0 |
| heap used after GC (MB) | 84.1 | 83.1 | -0.9 (-1.1%) |

<details><summary>Top components, causes and raw runs</summary>

**Top components** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `Button` | 302 | 88 | -214 (-70.9%) |
| `HugeiconsIcon` | 292 | 70 | -222 (-76.0%) |
| `Icon` | 292 | 70 | -222 (-76.0%) |
| `Block` | 256 | 0 | -256 (-100.0%) |
| `u0` | 120 | 120 | 0 (0.0%) |
| `BlockCard` | 0 | 48 | +48 |
| `AnimatePresence` | 42 | 42 | 0 (0.0%) |
| `FilterChip` | 35 | 35 | 0 (0.0%) |
| `PopChild` | 35 | 35 | 0 (0.0%) |
| `PopChildMeasure` | 35 | 35 | 0 (0.0%) |
| `PresenceChild` | 35 | 35 | 0 (0.0%) |

**Top render causes** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `HugeiconsIcon parent-same-props<-Icon` | 239 | 17 | -222 (-92.9%) |
| `Icon parent-same-props<-Button` | 218 | 4 | -214 (-98.2%) |
| `Block parent-same-props<-InfiniteScroll` | 208 | 0 | -208 (-100.0%) |
| `Button props:onDragStart,onClick,children` | 208 | 0 | -208 (-100.0%)
|
| `u0 context` | 120 | 120 | 0 (0.0%) |
| `Button mount` | 54 | 54 | 0 (0.0%) |
| `HugeiconsIcon mount` | 53 | 53 | 0 (0.0%) |
| `Icon mount` | 53 | 53 | 0 (0.0%) |
| `Block mount` | 48 | 0 | -48 (-100.0%) |
| `BlockCard mount` | 0 | 48 | +48 |
| `AnimatePresence state#5` | 30 | 30 | 0 (0.0%) |
| `Button props:onMouseEnter,onMouseLeave,onClick,children` | 30 | 30 |
0 (0.0%) |
| `PopChild props:children` | 30 | 30 | 0 (0.0%) |
| `PopChildMeasure props:children` | 30 | 30 | 0 (0.0%) |
| `PresenceChild props:children` | 30 | 30 | 0 (0.0%) |

<details><summary>Raw runs</summary>

| label | run | commit | commits | renders | renders/commit | script ms
| long tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before | 1 | 8eb406275 | 64 | 1,843 | 28.8 | 268 | 1 | 84.1 |
| before | 2 | 8eb406275 | 59 | 1,461 | 24.8 | 195 | 0 | 83.6 |
| before | 3 | 8eb406275 | 64 | 1,843 | 28.8 | 274 | 0 | 85.3 |
| after | 1 | 8eb406275 | 64 | 924 | 14.4 | 130 | 0 | 83.1 |
| after | 2 | 8eb406275 | 64 | 924 | 14.4 | 154 | 0 | 83.9 |
| after | 3 | 8eb406275 | 64 | 924 | 14.4 | 133 | 0 | 83.1 |

</details>


</details>
### builder-load-graph — median of 3 (before) vs 3 (after)

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 71 | 67 | -4 (-5.6%) |
| total renders | 80,164 | 80,104 | -60 (-0.1%) |
| renders per commit | 1,129.1 | 1,195.6 | +66.5 (+5.9%) |
| max renders in one commit | 25,160 | 25,160 | 0 (0.0%) |
| script time (ms) | 3,100 | 2,900 | -201 (-6.5%) |
| task time (ms) | 3,706 | 3,419 | -288 (-7.8%) |
| layout time (ms) | 51 | 44 | -7 (-13.1%) |
| style recalc time (ms) | 111 | 105 | -6 (-5.1%) |
| long tasks (≥ 50 ms) | 4 | 4 | 0 (0.0%) |
| long task total (ms) | 3,062 | 2,880 | -182 (-6.0%) |
| heap used after GC (MB) | 105.0 | 108.5 | +3.5 (+3.3%) |

<details><summary>Top components, causes and raw runs</summary>

**Top components** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `TooltipProvider` | 3,700 | 3,700 | 0 (0.0%) |
| `Text` | 3,370 | 3,370 | 0 (0.0%) |
| `Tooltip` | 3,103 | 3,103 | 0 (0.0%) |
| `TooltipContent` | 3,103 | 3,103 | 0 (0.0%) |
| `Primitive.div` | 3,075 | 3,075 | 0 (0.0%) |
| `HugeiconsIcon` | 3,017 | 3,014 | -3 (-0.1%) |
| `Icon` | 3,017 | 3,014 | -3 (-0.1%) |
| `Presence` | 2,825 | 2,825 | 0 (0.0%) |
| `Popper` | 2,823 | 2,823 | 0 (0.0%) |
| `PopperProvider` | 2,823 | 2,823 | 0 (0.0%) |

**Top render causes** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `TooltipProvider props:children` | 2,120 | 2,120 | 0 (0.0%) |
| `Presence props:children` | 2,044 | 2,044 | 0 (0.0%) |
| `HugeiconsIcon parent-same-props<-Icon` | 1,706 | 1,703 | -3 (-0.2%) |
| `Primitive.div props:children` | 1,663 | 1,663 | 0 (0.0%) |
| `Primitive.div.Slot props:children` | 1,663 | 1,663 | 0 (0.0%) |
| `Primitive.div.SlotClone props:children` | 1,663 | 1,663 | 0 (0.0%) |
| `Tooltip props:children` | 1,660 | 1,660 | 0 (0.0%) |
| `PopperAnchor props:children` | 1,417 | 1,417 | 0 (0.0%) |
| `Text mount` | 1,260 | 1,260 | 0 (0.0%) |
| `Text props:children` | 1,154 | 1,154 | 0 (0.0%) |

<details><summary>Raw runs</summary>

| label | run | commit | commits | renders | renders/commit | script ms
| long tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before | 1 | 8eb406275 | 71 | 80,164 | 1,129.1 | 3,244 | 4 | 108.1 |
| before | 2 | 8eb406275 | 71 | 80,164 | 1,129.1 | 3,022 | 4 | 104.8 |
| before | 3 | 8eb406275 | 74 | 80,193 | 1,083.7 | 3,100 | 4 | 105.0 |
| after | 1 | 8eb406275 | 67 | 80,104 | 1,195.6 | 2,749 | 4 | 105.5 |
| after | 2 | 8eb406275 | 70 | 80,153 | 1,145.0 | 2,911 | 5 | 108.5 |
| after | 3 | 8eb406275 | 67 | 80,104 | 1,195.6 | 2,900 | 4 | 108.9 |

</details>


</details>


**Screenshots (real app on :3000, end of each measured window):**

| | before | after |
| --- | --- | --- |
| `builder-type-field` (20 chars typed into node 1) | ![type
before](https://github.com/user-attachments/assets/b14dbc4e-1e15-45a7-973b-00cd527accb8)
| ![type
after](https://github.com/user-attachments/assets/e8e8db71-1e7a-4417-9971-db58af155d1c)
|
| `builder-block-menu-search` ("text", 6 scrolls) | ![menu
before](https://github.com/user-attachments/assets/5110eea8-0c81-4d87-89fd-4e74acb8f47c)
| ![menu
after](https://github.com/user-attachments/assets/65091df6-45f6-47b2-8fe8-a135fae12b7b)
|
| `builder-drag-running` | ![drag
before](https://github.com/user-attachments/assets/7e311269-070c-476e-bce1-cc763642b41b)
| ![drag
after](https://github.com/user-attachments/assets/7f730ee7-ec2a-459e-8216-54f58a942cbd)
|

**Negative check: same script on `dev` and on this branch** in a real
browser on the real backend. It searches the block menu, adds Store
Value and Calculator by click, types "hello" and undoes/redoes per
character and in bulk, drags a node and undoes/redoes the drag, saves,
reloads the saved workflow, then edits and undoes again. Every
observation is identical except `searchCalcCards`, the number of results
visible 1 s after searching. That is infinite-scroll timing: with 15
more scrolls, both versions return the same result ids in the same order
and without duplicates (dev 168 / branch 176 by the time they were
counted, identical prefix).

| check | dev | this PR |
| --- | --- | --- |
| `searchCalcCards` | `32` | `24` |
| `searchCalcFirstCard` | `"Calculator"` | `"Calculator"` |
| `nodesAfterAdding` | `2` | `2` |
| `undoEnabledAfterAdding` | `true` | `true` |
| `fieldAfterTyping` | `"hello"` | `"hello"` |
| `fieldAfterOneUndo` | `"hell"` | `"hell"` |
| `fieldAfterRedo` | `"hello"` | `"hello"` |
| `fieldAfterFiveUndos` | `""` | `""` |
| `nodesAfterFiveUndos` | `2` | `2` |
| `fieldAfterFiveRedos` | `"hello"` | `"hello"` |
| `redoEnabledAtEnd` | `false` | `false` |
| `dragMovedNode` | `true` | `true` |
| `undoRestoresDragPosition` | `true` | `true` |
| `redoReappliesDrag` | `true` | `true` |
| `savedHasFlowID` | `true` | `true` |
| `nodesAfterLoad` | `2` | `2` |
| `fieldAfterLoad` | `"hello"` | `"hello"` |
| `undoEnabledAfterLoad` | `false` | `false` |
| `redoEnabledAfterLoad` | `false` | `false` |
| `draftPopupAfterLoad` | `false` | `false` |
| `fieldAfterEditOnLoaded` | `"hello!"` | `"hello!"` |
| `undoEnabledAfterEditOnLoaded` | `true` | `true` |
| `fieldAfterUndoOnLoaded` | `"hello"` | `"hello"` |
| `pageErrors` | `[]` | `[]` |

![after edit +
undo/redo](https://github.com/user-attachments/assets/c101909b-26ed-4a3d-897e-2616ef06e528)
![saved workflow
loaded](https://github.com/user-attachments/assets/44abf319-37f4-4541-ada8-de46990bb649)

**Targeted tests:**

```
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > addNodes > adds a whole workflow in one store write 3ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > addNodes > appends after the nodes already on the canvas, in order 1ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > addNodes > writes nothing for an empty list 0ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > addNodes > does not add an undo entry, as before 1ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > addNodes > finds the added nodes by id 0ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > updateNodeData > a changed value writes once and can be undone 1ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > updateNodeData > an equal value writes nothing and keeps the node object 0ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > updateNodeData > an unknown node writes nothing 0ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > updateNodeData > a key the node lacks is still written when its value is undefined 0ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > updateNodeData > one changed key among equal ones still writes 0ms
 ✓ src/app/(platform)/build/__tests__/nodeStore.writes.test.ts > updateNodeData > updates every node that shares the id, as before 0ms
 ✓ src/app/(platform)/build/components/FlowEditor/Flow/__tests__/Flow.isolation.test.tsx > Flow canvas isolation > a drag frame re-renders the canvas but not the panels beside it 22ms
 ✓ src/app/(platform)/build/components/FlowEditor/Flow/__tests__/Flow.isolation.test.tsx > Flow canvas isolation > a field edit re-renders the canvas but not the panels beside it 4ms
 ✓ src/app/(platform)/build/components/FlowEditor/Flow/__tests__/Flow.isolation.test.tsx > Flow canvas isolation > controls inside the canvas still re-render with it 2ms
 ✓ src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/__tests__/BlockMenuSearchContent.rerender.test.tsx > BlockMenuSearchContent > filter counts and the creator list arriving do not re-render the result rows 74ms
 ✓ src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/__tests__/BlockMenuSearchContent.rerender.test.tsx > BlockMenuSearchContent > a new search query still re-renders the rows with the new highlight 36ms
 ✓ src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/__tests__/BlockMenuSearchContent.rerender.test.tsx > getSearchItemKeys > keys each result by type and id, and keeps repeats unique 1ms
 ✓ src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/__tests__/BlockMenuSearchContent.rerender.test.tsx > getSearchItemKeys > a result keeps its key when an earlier one leaves the list 1ms
 Test Files  3 passed (3)
      Tests  18 passed (18)
```

Also run: `pnpm vitest run src/app/(platform)/build scripts/perf` (61
files / 604 tests pass), `pnpm format`, `pnpm lint` (no errors), `pnpm
types`.

### Left out on purpose (follow-ups)

- **One undo entry per field edit, and debouncing field edits**
(ticket's "local draft in the widget" + `pushState` coalescing). This
changes undo granularity and needs `FormCreator.tsx`/widgets, which are
outside this PR's files. A naive coalesce in `historyStore` can also
leave an entry equal to the current state at the top of `past`, and
`undo()` then stops early on it. Left for a ticket that may change undo
behaviour.
- **`canUndo`/`canRedo` as stored booleans.** `UndoRedoButtons` (out of
scope) reads `canUndo()` in render against the live node store. Storing
booleans would change when the button is enabled (e.g. after a small
drag or an execution status update). Kept as is; `historyStore.ts` is
unchanged.
- **`GraphSearchMenu` (`GraphMenu.tsx`, `useGraphMenuSearchBar.tsx`) and
`AgentOutputs.tsx`** still subscribe to `nodes` and render on every node
change (61 renders per drag window, 20 per typing window).
- **`edgeStore.addLinks`** is still one write plus one `pushState` per
link on load. **`syncHardcodedValuesWithHandleIds`** still clones with
`JSON.parse(JSON.stringify())`. Both are in files or actions outside
this PR.
- **`react-window` virtualisation** for the all-blocks and search lists:
layout and infinite-scroll risk, so left for its own change.
- Unrelated bugs seen while measuring:
- `AllBlocksContent.tsx` renders its "Error loading blocks" card when
`isErrorOnLoadingMore` is false (inverted condition).
- Switching workflows in-app (`builder-load-graph`) shows "Unsaved
changes found" for the new workflow, on `dev` too.

### Agents and large language models used

- Claude Code (Conductor) 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] New Vitest tests fail on `dev` and pass here (canvas isolation,
`addNodes`/`updateNodeData` writes, block-menu row re-renders)
- [x] Existing builder + perf-harness tests pass (61 files / 604 tests)
- [x] `pnpm perf` before/after on `builder-drag-running`,
`builder-type-field`, `builder-block-menu-search`, `builder-load-graph`
- [x] Real-browser negative check identical on `dev` and this branch:
block-menu search + add, field edit, undo/redo (per char and bulk), drag
+ undo/redo, save, reload saved workflow

#### 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**). None.

🤖 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/122)

---------

Co-authored-by: Claude Opus 5.5 1M context (Claude Code) <[email protected]>
2026-10-19 23:54:30 +00:00
Abhimanyu YadavandClaude Opus 5.5 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 | ede2df2ae | 38 | 8,462 | 222.7 | 720 | 6 | 44.0 |
| before | 2 | ede2df2ae | 40 | 9,722 | 243.1 | 789 | 6 | 43.3 |
| before | 3 | ede2df2ae | 41 | 9,722 | 237.1 | 785 | 6 | 43.2 |
| after | 1 | ede2df2ae | 42 | 3,390 | 80.7 | 350 | 1 | 43.1 |
| after | 2 | ede2df2ae | 39 | 3,148 | 80.7 | 338 | 1 | 43.1 |
| after | 3 | ede2df2ae | 39 | 3,148 | 80.7 | 357 | 1 | 42.9 |

</details>

| before (`dev`) | after |
| --- | --- |
|
![perf-before](https://github.com/user-attachments/assets/179f25db-1ea6-43dc-84fd-02d4fad5d643)
|
![perf-after](https://github.com/user-attachments/assets/ac645d3c-c484-4189-a18d-5342e863e661)
|

#### Real app, real backend, real websocket (negative check: the page
still works the same)

A real workflow with real runs: the executor ran them and the backend's
websocket delivered the new one. The check, scripted with Playwright on
:3000, selects the third run, opens a row's actions menu, then starts a
new run through the API and waits for it to appear. Both sides ran on
the same branch, swapping only the app files back to `dev`.

| check | before (`dev`) | after |
| --- | --- | --- |
| first run selected on load | ✅ | ✅ |
| clicking the 3rd run selects it (URL, highlight) | ✅ | ✅ |
| detail shows that run's input | ✅ | ✅ |
| actions menu shows "Save as template", "Delete task" | ✅ | ✅ |
| new run appears at the top; selection kept | ✅ | ✅ |
| selected row keeps its DOM element | ❌ (replaced) | ✅ |
| `TaskListItem` / `SidebarItemCard` / `TaskActionsDropdown` renders
when the new run arrives | 30 / 30 / 30 (all 10 rows, 3 times each) | 1
/ 1 / 1 |
| total renders in the 4 s after the new run arrives | 2,166 | 832 |

Keyboard focus shows the index-key bug. Both screenshots were taken
after the actions menu on the 2nd run was opened and closed, and then a
new run arrived. On `dev` the focus ring stayed on the 2nd slot, which
now holds a different run. With the fix it moved with its run to the 3rd
slot.

| before (`dev`) | after |
| --- | --- |
|
![app-before](https://github.com/user-attachments/assets/d06d13be-8955-42a3-b855-2b7ac708a136)
|
![app-after](https://github.com/user-attachments/assets/11c3c872-939c-4487-acc3-b0f62a9f289e)
|

#### Targeted tests

Each new test fails without its fix. With the app files reverted,
`runs-list-prepend` fails on the DOM-identity assertion, and
`useSelectedRunView` sees 4 requests instead of 2 while the tab is
hidden.

```
  ✓ src/components/atoms/RelativeTime/__tests__/RelativeTime.test.tsx > RelativeTime > shows the time relative to now, as formatDistanceToNow does
  ✓ src/components/atoms/RelativeTime/__tests__/RelativeTime.test.tsx > RelativeTime > updates on the minute without its parent re-rendering
  ✓ src/components/atoms/RelativeTime/__tests__/RelativeTime.test.tsx > RelativeTime > shares one timer between every label on the page
  ✓ src/components/molecules/InfiniteList/__tests__/InfiniteList.test.tsx > InfiniteList > keeps each row's state with its item when an item is prepended
  ✓ src/components/molecules/InfiniteList/__tests__/InfiniteList.test.tsx > InfiniteList > keys rows by position when no getKey is given
  ✓ src/app/(platform)/library/agents/[id]/__tests__/runs-list-prepend.test.tsx > a run prepended to the list renders only the new row and keeps the others' elements
  ✓ src/app/(platform)/library/agents/[id]/__tests__/runs-list-prepend.test.tsx > clicking a run selects it through the latest onSelectRun
  ✓ src/app/(platform)/library/agents/[id]/__tests__/runs-list-prepend.test.tsx > a run's label shows when it started, relative to now
  ✓ src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/__tests__/useSelectedRunView.test.tsx > polls a running run while the tab is visible and pauses while it is hidden
  Test Files  4 passed (4)
  Tests  9 passed (9)
```

The full suite (`pnpm test:unit`) passes: 1010 files, 10842 tests. `pnpm
format`, `pnpm lint` and `pnpm types` are clean. `pnpm build`
(production) succeeds.

### Deliberately left out (follow-ups)

From the ticket, left out to keep this change small and provably safe:
- **Run modal** (`RunAgentModal` / `ModalRunSection` /
`useAgentRunModal`): memoising the context value alone does not stop the
per-keystroke re-render of every input, because `inputValues` is part of
that value and `ModalRunSection` renders every row inline. The real fix
(memoised input rows with a stable `setInputValue(key)`) needs
`ModalRunSection`, which is outside this PR's file list, so I shipped no
unmeasured change here.
- **`NewAgentLibraryView`**: the stable callback now lives inside
`SidebarRunsList`, so the page itself is unchanged. Memoising the banner
and moving the dialog open flags only pays off once `SelectedRunView` is
memoised.
- **`SelectedRunView` re-renders on every 1.5 s poll** because `stats`
changes. This is most of the remaining ~3,100 renders in the scenario.
Selecting only the fields it shows is a follow-up.
- **The other ~48 one-shot `formatDistanceToNow` labels**
(`ActivityItem`, `ScheduleListItem`, `TriggerListItem`,
`RunDetailHeader`, …) can move to `RelativeTime` one call site at a
time.
- **Equality early-returns** in `AgentActivityDropdown/helpers.tsx`
(`handleExecutionUpdate`) and in the `useCopilotNotifications` storage
handler: copilot/navbar files, out of scope here.
- **Virtualising the runs list** (react-window over ~100 rows): with
memoised rows the list no longer re-renders as a whole, so this is not
needed for this scenario.
- **Hidden-tab request count in the perf harness**: in this sandbox
neither headless nor Xvfb-headed Chromium reports
`document.visibilityState === "hidden"` for a background or minimised
page, so the scenario does not measure it. A Vitest test covers it
through React Query's `focusManager`, which reads that state.

### Agents and large language models used

- Claude Code (Conductor) 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] `library-agent-runs` before/after, 3 runs each (`pnpm perf` +
`pnpm perf:compare`)
- [x] Real app on :3000 with a real workflow: selection, run detail,
actions menu, and a new run arriving over the real websocket, on `dev`
and on this branch
- [x] New tests fail without the fix and pass with it; full `pnpm
test:unit` passes
  - [x] `pnpm format`, `pnpm lint`, `pnpm types`, `pnpm build`

#### 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**). None.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-10-19 22:09:48 +00:00
Abhimanyu YadavandClaude Opus 5.5 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 | 6d793e53d | 79 | 54,037 | 684.0 | 2,505 | 17 | 97.1 |
| before2 | 2 | 6d793e53d | 85 | 54,078 | 636.2 | 2,142 | 16 | 99.4 |
| before2 | 3 | 6d793e53d | 85 | 54,078 | 636.2 | 2,403 | 17 | 100.7 |
| before2 | 4 | 6d793e53d | 84 | 53,987 | 642.7 | 2,180 | 11 | 103.1 |
| before2 | 5 | 6d793e53d | 79 | 53,947 | 682.9 | 2,332 | 12 | 98.0 |
| after2 | 1 | 6d793e53d | 54 | 53,801 | 996.3 | 2,045 | 12 | 100.0 |
| after2 | 2 | 6d793e53d | 53 | 53,860 | 1,016.2 | 2,067 | 17 | 105.5 |
| after2 | 3 | 6d793e53d | 56 | 53,817 | 961.0 | 2,326 | 12 | 95.8 |
| after2 | 4 | 6d793e53d | 54 | 53,815 | 996.6 | 2,538 | 13 | 97.3 |
| after2 | 5 | 6d793e53d | 53 | 53,785 | 1,014.8 | 2,041 | 12 | 100.9 |

</details>

### builder-drag-running — median of 5 (before2) vs 5 (after2)

| metric | before2 | after2 | Δ |
| --- | --- | --- | --- |
| commits | 211 | 208 | -3 (-1.4%) |
| total renders | 34,176 | 34,158 | -18 (-0.1%) |
| renders per commit | 162.0 | 164.2 | +2.2 (+1.3%) |
| max renders in one commit | 11,095 | 11,095 | 0 (0.0%) |
| script time (ms) | 2,475 | 2,651 | +176 (+7.1%) |
| task time (ms) | 3,978 | 4,213 | +235 (+5.9%) |
| layout time (ms) | 40 | 42 | +2 (+5.4%) |
| style recalc time (ms) | 79 | 79 | -0 (-0.5%) |
| long tasks (≥ 50 ms) | 40 | 44 | +4 (+10.0%) |
| long task total (ms) | 2,665 | 3,163 | +497 (+18.7%) |
| heap used after GC (MB) | 94.4 | 93.9 | -0.5 (-0.5%) |

**Top components** (median renders)

| name | before2 | after2 | Δ |
| --- | --- | --- | --- |
| `TooltipProvider` | 1,824 | 1,824 | 0 (0.0%) |
| `Presence` | 1,701 | 1,701 | 0 (0.0%) |
| `HugeiconsIcon` | 1,446 | 1,446 | 0 (0.0%) |
| `Icon` | 1,446 | 1,446 | 0 (0.0%) |
| `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 | before2 | after2 | Δ |
| --- | --- | --- | --- |
| `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,446 | 0 (0.0%) |
| `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 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before2 | 1 | 6d793e53d | 212 | 34,194 | 161.3 | 2,942 | 46 | 94.4 |
| before2 | 2 | 6d793e53d | 212 | 34,191 | 161.3 | 2,386 | 40 | 92.9 |
| before2 | 3 | 6d793e53d | 209 | 34,163 | 163.5 | 2,847 | 46 | 95.7 |
| before2 | 4 | 6d793e53d | 211 | 34,176 | 162.0 | 2,475 | 37 | 94.4 |
| before2 | 5 | 6d793e53d | 207 | 34,138 | 164.9 | 2,368 | 40 | 95.4 |
| after2 | 1 | 6d793e53d | 212 | 34,190 | 161.3 | 2,339 | 38 | 96.3 |
| after2 | 2 | 6d793e53d | 213 | 34,185 | 160.5 | 2,187 | 39 | 91.2 |
| after2 | 3 | 6d793e53d | 208 | 34,144 | 164.2 | 2,651 | 47 | 94.4 |
| after2 | 4 | 6d793e53d | 208 | 34,158 | 164.2 | 2,835 | 51 | 93.9 |
| after2 | 5 | 6d793e53d | 206 | 34,131 | 165.7 | 2,679 | 44 | 93.9 |

</details>

**How to read these honestly:**
- `builder-run-beads`: the per-frame bead renders are gone (150 → 12,
one per bead created or removed), and commits drop by about 35% (84 →
54). Total renders barely move (−0.4%), because the window is dominated
by 7,285-render commits: every node re-renders on each new execution.
That is outside this ticket (FormCreator/CustomNode, SECRT-3002/3012).
Script time is −11% in this round but was +10% in round 1 (3 runs), and
the run-to-run spread is 15–24%. **I make no timing claim.**
- `builder-drag-running`: render counts are unchanged (±0.2%), as
expected. During a drag only the dragged node and its edge re-render
(`CustomNode props:positionAbsoluteX`, `JSBeads props:edgePath`); other
nodes already render 0 times per frame on `dev`. The changes here make
each of those renders cheaper: no path measurement or effect restart per
`JSBeads` render, no `find` per selector, no item rebuild per viewer.
They do not show up beyond noise: script time was −3.1% in round 1 and
+7.1% in round 2, with a 23–24% spread. **I make no timing claim.**

**Screenshots (real app, `:3000`).** Drag scenario at the end of the
window: statuses, status rings, output previews and edges are identical.

| before | after |
| --- | --- |
| ![drag
before](https://github.com/user-attachments/assets/54013b6a-6573-4a56-a19c-c4c2b2d86d1e)
| ![drag
after](https://github.com/user-attachments/assets/394e0788-592a-4ae5-aa5f-e3b9e1640a02)
|

Beads travelling into the StoreValue inputs, 330 ms after the first
event, zoomed in (`builder-run-beads` fixture, captured with a throwaway
Playwright script). Both versions draw, move and park the beads the same
way. The "after" bead is further along because of the real-time delta
described above.

| before | after |
| --- | --- |
| ![beads
before](https://github.com/user-attachments/assets/b2b978ac-6af8-45ea-8d05-f027e9312055)
| ![beads
after](https://github.com/user-attachments/assets/bcafb88e-90e1-46ef-8a9f-f39b1c777d24)
|

**Negative check:** at the end of the bead scenario, both versions leave
exactly **24** `<circle>`s on the canvas (the parked beads of the
original run). The new beads were created and removed the same way, and
the end state matches:

| before (end) | after (end) |
| --- | --- |
| ![beads end
before](https://github.com/user-attachments/assets/859864b9-929b-4dac-8015-292e2dad9871)
| ![beads end
after](https://github.com/user-attachments/assets/ad24a49d-2a62-4624-a07f-c8e52785fe72)
|

**Targeted tests.** 11 pass on this branch. 6 of them fail on `dev`'s
versions of the four files: per-frame commits (34 vs ≤ 3), path measured
on every render, `nodes.find` per read, and items rebuilt on re-render.
The other 5 are behaviour-parity checks (parked bead at the same spot,
correct reads after every kind of write, first match wins for duplicate
ids, only node B re-renders when B's status changes, items newest-first)
and pass on both.

```
✓ src/app/(platform)/build/components/FlowEditor/edges/components/__tests__/JSBeads.test.tsx > JSBeads > moves a bead along the edge and removes it on arrival, without a render per frame 21ms
✓ src/app/(platform)/build/components/FlowEditor/edges/components/__tests__/JSBeads.test.tsx > JSBeads > parks a bead one diameter before the end until its node takes it 4ms
✓ src/app/(platform)/build/components/FlowEditor/edges/components/__tests__/JSBeads.test.tsx > JSBeads > measures each distinct edge path once and follows the edge when it moves 4ms
✓ src/app/(platform)/build/components/FlowEditor/edges/components/__tests__/JSBeads.test.tsx > JSBeads > does not measure an edge that carries no beads 2ms
✓ src/app/(platform)/build/__tests__/nodeStore.byId.test.tsx > nodeStore lookups by id > does not scan the nodes array for each node's read 5ms
✓ src/app/(platform)/build/__tests__/nodeStore.byId.test.tsx > nodeStore lookups by id > reads the current values after every kind of write 3ms
✓ src/app/(platform)/build/__tests__/nodeStore.byId.test.tsx > nodeStore lookups by id > returns the first node when two share an id, as find did 0ms
✓ src/app/(platform)/build/__tests__/nodeStore.byId.test.tsx > NodeContainer > re-renders only the node whose status changed 22ms
✓ src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/__tests__/useNodeDataViewer.test.ts > useNodeDataViewer > builds the items newest execution first 14ms
✓ src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/__tests__/useNodeDataViewer.test.ts > useNodeDataViewer > does not rebuild the items when its node re-renders with the same data 3ms
✓ src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/__tests__/useNodeDataViewer.test.ts > useNodeDataViewer > rebuilds the items when new output arrives 2ms
Test Files  3 passed (3)
Tests  11 passed (11)
```

After review (CodeRabbit), `setNodes` also drops the index of the array
it is given, so a caller that mutates an array it already passed in and
sets it again cannot read a stale node. A 12th test, `reads a node the
caller replaced in an array it sets again`, covers this and fails
without the fix. This does not change any measured path.

Also run: `pnpm vitest run "src/app/(platform)/build" scripts/perf` (58
files, 585 tests pass); `pnpm format`, `pnpm lint`, `pnpm types` are
clean. The full `pnpm test:unit` run had 23 timeouts in unrelated suites
(auth, team, admin, billing, copilot) while the dev server and
Playwright were running on the same 16 GB machine. Re-run on their own,
those 6 files pass: 297/297.

### Hand-off

- **Shared files touched (append-only):**
`scripts/perf/scenarios/registry.ts` (+2 lines),
`scripts/perf/scenarios/builder-run-events.ts` (`export` on
`routeExecutionSocket`/`withTimeout`),
`docs/engineering/frontend-perf-measurement.md` (+1 row),
`docs/engineering/frontend-perf-baseline.md` (+1 section at the end).
`nodeStore.ts` changes only the five getter bodies plus a helper above
`applyExecutionUpdate`, so SECRT-3012 should rebase cleanly.
- **Left out of scope (follow-ups):**
- Deferring `useNodeDataViewer` until the dialog opens needs
`NodeDataViewer.tsx` to move the hook into the dialog content. That file
was out of this workspace's scope. The memo covers re-renders but not
the first mount. The ticket's "1 MB output < 5 ms with the dialog
closed" target depends on that change and on `ContentRenderer`'s bounded
serializer, and was not measured here.
- `CustomEdge.tsx` `beadsKey` still includes coordinates, so bead
circles remount on each drag frame. That is now cheap: the layout effect
places them before paint.
- `ContentRenderer` full `JSON.stringify` then `slice`, the
`OutputRenderers` `getRenderer` cache,
`FieldError`/`CredentialField`/`NodeCost` selectors, and `ViewMoreData`
reversing in render.
- Pre-existing and unchanged: a bead whose `beadDown` arrives after the
bead has parked stays on the edge until the counters reset (the 24
parked circles above). Both versions do this.
- The 7,285-render commits on each new execution event (every
`CustomNode`/form re-rendering on `props:data`) dominate both builder
run scenarios.

### Agents and large language models used

- Claude Code (Conductor) 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] Targeted Vitest suites (above) pass; 6 of 11 fail on `dev`'s code,
the rest are parity checks
- [x] `src/app/(platform)/build` and `scripts/perf` suites pass (585
tests)
- [x] `pnpm perf` `builder-drag-running` and `builder-run-beads`, before
and after, alternated, 5 runs each
- [x] Real-app screenshots: drag with a running execution, beads
mid-flight and at the end; same bead count at the end on both versions

#### 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**) (none)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-10-19 20:53:53 +00:00
Abhimanyu YadavandClaude Opus 5.5 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 | 6d793e53d | 190 | 574,001 | 3,021.1 | 17,358 | 60 | 84.1
|
| before | 2 | 6d793e53d | 196 | 574,251 | 2,929.9 | 17,819 | 60 | 86.5
|
| before | 3 | 6d793e53d | 193 | 574,244 | 2,975.4 | 18,387 | 60 | 95.6
|
| after | 1 | 6d793e53d | 67 | 1,986 | 29.6 | 326 | 0 | 86.1 |
| after | 2 | 6d793e53d | 67 | 1,986 | 29.6 | 318 | 0 | 84.9 |
| after | 3 | 6d793e53d | 68 | 1,987 | 29.2 | 316 | 0 | 85.1 |

</details>

The remaining ~30 renders per commit are the panel itself
(`ContextPanel` → its empty state and `Dialog`) re-rendering once per
frame for the new width, which is what has to render.

<table><tr><th>before (end of drag)</th><th>after (end of
drag)</th></tr>
<tr><td><img
src="https://github.com/user-attachments/assets/0f79211c-2e2b-4b0f-b763-cae44e6bc1e6"
alt="before: files panel after the drag"></td><td><img
src="https://github.com/user-attachments/assets/d3a720d2-3ce4-48bf-9abf-e6989cd9fbdd"
alt="after: files panel after the drag"></td></tr></table>

#### `copilot-background-stream`: unchanged behaviour, fewer renders

### copilot-background-stream — median of 3 (before) vs 3 (after)

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 7 | 5 | -2 (-28.6%) |
| total renders | 746 | 356 | -390 (-52.3%) |
| renders per commit | 106.6 | 71.2 | -35.4 (-33.2%) |
| max renders in one commit | 390 | 211 | -179 (-45.9%) |
| script time (ms) | 83 | 41 | -42 (-50.6%) |
| task time (ms) | 212 | 244 | +32 (+15.1%) |
| layout time (ms) | 0 | 0 | 0 |
| style recalc time (ms) | 0 | 0 | 0 |
| long tasks (≥ 50 ms) | 1 | 1 | 0 (0.0%) |
| long task total (ms) | 53 | 56 | +4 (+7.0%) |
| heap used after GC (MB) | 61.4 | 61.2 | -0.2 (-0.3%) |

**Top components** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `Presence` | 40 | 17 | -23 (-57.5%) |
| `motion.span` | 28 | 14 | -14 (-50.0%) |
| `Popper` | 26 | 13 | -13 (-50.0%) |
| `PopperAnchor` | 26 | 13 | -13 (-50.0%) |
| `PopperProvider` | 26 | 13 | -13 (-50.0%) |
| `Primitive.button` | 26 | 13 | -13 (-50.0%) |
| `Primitive.button.Slot` | 26 | 13 | -13 (-50.0%) |
| `Primitive.button.SlotClone` | 26 | 13 | -13 (-50.0%) |
| `Primitive.div` | 26 | 13 | -13 (-50.0%) |
| `Primitive.div.Slot` | 26 | 13 | -13 (-50.0%) |

**Top render causes** (median renders)

| name | before | after | Δ |
| --- | --- | --- | --- |
| `Presence props:children` | 40 | 17 | -23 (-57.5%) |
| `Primitive.div props:children` | 26 | 13 | -13 (-50.0%) |
| `Primitive.div.Slot props:children` | 26 | 13 | -13 (-50.0%) |
| `Primitive.div.SlotClone props:children` | 26 | 13 | -13 (-50.0%) |
| `motion.span props:style,children` | 22 | 11 | -11 (-50.0%) |
| `Tooltip props:children` | 22 | 11 | -11 (-50.0%) |
| `HugeiconsIcon parent-same-props<-Icon` | 20 | 10 | -10 (-50.0%) |
| `Popper props:children` | 18 | 9 | -9 (-50.0%) |
| `PopperAnchor props:children` | 18 | 9 | -9 (-50.0%) |
| `PopperProvider props:children` | 18 | 9 | -9 (-50.0%) |

<details><summary>Raw runs</summary>

| label | run | commit | commits | renders | renders/commit | script ms
| long tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before | 1 | 6d793e53d | 7 | 746 | 106.6 | 84 | 2 | 61.5 |
| before | 2 | 6d793e53d | 7 | 746 | 106.6 | 83 | 1 | 61.4 |
| before | 3 | 6d793e53d | 7 | 746 | 106.6 | 79 | 1 | 61.4 |
| after | 1 | 6d793e53d | 5 | 356 | 71.2 | 44 | 1 | 61.2 |
| after | 2 | 6d793e53d | 5 | 356 | 71.2 | 41 | 1 | 61.2 |
| after | 3 | 6d793e53d | 5 | 356 | 71.2 | 36 | 1 | 61.2 |

</details>

The 390 renders that go away are one commit in which an unrelated store
write on Home re-rendered every whole-store subscriber (`ChatHostBody
state#81`, `ChatInput state#2`, `ChatSidebar state#21`,
`NotificationToggle/NotificationDialog state#2`, `ConnectionPicker
state#8`, all from the full per-component ranking). None of them render
for it now. The sidebar's own session-list render is still there on both
sides.

#### `copilot-stream-reply`: no change (alternated, 5 runs each)

### copilot-stream-reply — median of 5 (before2) vs 5 (after2)

| metric | before2 | after2 | Δ |
| --- | --- | --- | --- |
| commits | 139 | 139 | 0 (0.0%) |
| total renders | 343,814 | 343,806 | -8 (-0.0%) |
| renders per commit | 2,473.5 | 2,455.9 | -17.6 (-0.7%) |
| max renders in one commit | 9,299 | 9,299 | 0 (0.0%) |
| script time (ms) | 9,803 | 9,764 | -39 (-0.4%) |
| task time (ms) | 10,268 | 10,264 | -5 (-0.0%) |
| layout time (ms) | 30 | 35 | +6 (+19.1%) |
| style recalc time (ms) | 103 | 104 | +1 (+0.6%) |
| long tasks (≥ 50 ms) | 36 | 36 | 0 (0.0%) |
| long task total (ms) | 9,855 | 9,838 | -17 (-0.2%) |
| heap used after GC (MB) | 98.1 | 91.9 | -6.2 (-6.3%) |

**Top components** (median renders)

| name | before2 | after2 | Δ |
| --- | --- | --- | --- |
| `Tooltip` | 25,335 | 25,335 | 0 (0.0%) |
| `TooltipContent` | 25,334 | 25,334 | 0 (0.0%) |
| `HugeiconsIcon` | 14,455 | 14,455 | 0 (0.0%) |
| `Icon` | 14,455 | 14,455 | 0 (0.0%) |
| `Presence` | 12,957 | 12,957 | 0 (0.0%) |
| `TooltipProvider` | 12,812 | 12,812 | 0 (0.0%) |
| `Popper` | 12,773 | 12,773 | 0 (0.0%) |
| `PopperAnchor` | 12,773 | 12,773 | 0 (0.0%) |
| `PopperProvider` | 12,773 | 12,773 | 0 (0.0%) |
| `Primitive.button` | 12,773 | 12,773 | 0 (0.0%) |

**Top render causes** (median renders)

| name | before2 | after2 | Δ |
| --- | --- | --- | --- |
| `Tooltip props:children` | 25,274 | 25,274 | 0 (0.0%) |
| `HugeiconsIcon parent-same-props<-Icon` | 14,452 | 14,452 | 0 (0.0%) |
| `Presence props:children` | 12,955 | 12,955 | 0 (0.0%) |
| `TooltipProvider props:children` | 12,808 | 12,808 | 0 (0.0%) |
| `Primitive.div props:children` | 12,771 | 12,771 | 0 (0.0%) |
| `Primitive.div.Slot props:children` | 12,771 | 12,771 | 0 (0.0%) |
| `Primitive.div.SlotClone props:children` | 12,771 | 12,771 | 0 (0.0%)
|
| `PopperAnchor props:children` | 12,738 | 12,738 | 0 (0.0%) |
| `Popper props:children` | 12,736 | 12,736 | 0 (0.0%) |
| `PopperProvider props:children` | 12,736 | 12,736 | 0 (0.0%) |

<details><summary>Raw runs</summary>

| label | run | commit | commits | renders | renders/commit | script ms
| long tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| before2 | 1 | 6d793e53d | 139 | 343,814 | 2,473.5 | 9,796 | 36 | 100.2
|
| before2 | 2 | 6d793e53d | 140 | 353,117 | 2,522.3 | 9,916 | 37 | 98.1
|
| before2 | 3 | 6d793e53d | 139 | 343,804 | 2,473.4 | 9,681 | 36 | 91.8
|
| before2 | 4 | 6d793e53d | 137 | 343,797 | 2,509.5 | 10,010 | 36 |
102.1 |
| before2 | 5 | 6d793e53d | 143 | 353,130 | 2,469.4 | 9,803 | 37 | 93.8
|
| after2 | 1 | 6d793e53d | 143 | 353,138 | 2,469.5 | 9,821 | 37 | 97.9 |
| after2 | 2 | 6d793e53d | 140 | 343,821 | 2,455.9 | 9,626 | 36 | 91.9 |
| after2 | 3 | 6d793e53d | 139 | 325,862 | 2,344.3 | 9,762 | 34 | 91.9 |
| after2 | 4 | 6d793e53d | 133 | 325,620 | 2,448.3 | 9,764 | 34 | 96.4 |
| after2 | 5 | 6d793e53d | 139 | 343,806 | 2,473.4 | 9,790 | 36 | 89.6 |

</details>

A first back-to-back pair (3 runs each) read +8.4% renders after:

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 135 | 147 | +12 (+8.9%) |
| total renders | 334,482 | 362,464 | +27,982 (+8.4%) |
| renders per commit | 2,479.2 | 2,462.2 | -17.0 (-0.7%) |
| max renders in one commit | 9,299 | 9,299 | 0 (0.0%) |
| script time (ms) | 9,810 | 9,632 | -178 (-1.8%) |
| task time (ms) | 10,325 | 10,110 | -215 (-2.1%) |
| layout time (ms) | 31 | 35 | +4 (+13.7%) |
| style recalc time (ms) | 98 | 104 | +6 (+6.2%) |
| long tasks (≥ 50 ms) | 35 | 38 | +3 (+8.6%) |
| long task total (ms) | 9,879 | 9,697 | -182 (-1.8%) |
| heap used after GC (MB) | 96.7 | 93.8 | -2.9 (-3.0%) |

Both sides move in steps of ~9.3k renders, one full-transcript re-render
at the end of a turn (the known nondeterminism in
`frontend-perf-baseline.md`). So I alternated: base files → `before2` (5
runs), then this branch → `after2` (5 runs). The medians match to 8
renders, so the first reading was that noise. During a streamed turn no
unrelated store write happens, so this scenario was never expected to
move. `ChatSidebar` renders once in both (its session-list hook, not the
store), and `RecentChats` and `MobileDrawer` render 0 in both.

#### Negative checks: the feature works the same (real app, same script
on base and on this branch)

A Playwright script drove the real app on :3000 against the perf
fixtures, first with the base files and then with this branch:

| check | before | after |
| --- | --- | --- |
| files panel width on open | 432 | 432 |
| width mid-drag (30 of 60 moves, 2 px each) | 492 | 492 |
| width after the paced drag (60 moves) | 552 | 552 |
| width after a 20-move burst with no waits (many moves per frame), 100
px back | 452 | 452 |
| drag past the cap | 600 | 600 |
| `copilot-context-panel-width` in localStorage | "600" | "600" |
| width after reload | 600 | 600 |
| sidebar "Delete chat" dialog names the chosen chat, Cancel closes it
(`sessionToDelete`) | yes | yes |
| notification popover opens (`isNotificationsEnabled`,
`isSoundEnabled`) | yes | yes |

`copilot-stream-reply` also sends "go" through the composer and streams
the reply on both sides (screenshots identical), which exercises
`useChatInput`, `notifyMessageSent` and `useCopilotPage`.

<table><tr><th>before: delete dialog</th><th>after: delete
dialog</th></tr>
<tr><td><img
src="https://github.com/user-attachments/assets/ce300110-ba99-4b21-a9b5-87d9e27bf75b"
alt="before: delete chat dialog"></td><td><img
src="https://github.com/user-attachments/assets/d38538b2-13d3-4149-9437-c3c4938bc8d3"
alt="after: delete chat dialog"></td></tr>
<tr><th>before: notifications popover</th><th>after: notifications
popover</th></tr>
<tr><td><img
src="https://github.com/user-attachments/assets/4baca6a2-a600-4476-9e6f-bf33aa8e58bf"
alt="before: notifications popover"></td><td><img
src="https://github.com/user-attachments/assets/db6ead0c-2f8d-4088-9bfd-8bc1d2530f26"
alt="after: notifications popover"></td></tr></table>

#### Targeted tests

Without the fix, all 7 tests in `store-selectors.test.tsx`, the
`useCopilotPage` "does not re-render…" test, the ChatSidebar test, and
the two frame-coalescing `PanelResizeHandle` tests fail. The other four
`PanelResizeHandle` tests are regression guards that pass on both sides.

```
✓ components/__tests__/PanelResizeHandle.test.tsx > PanelResizeHandle > writes one width for ten pointer moves within one frame 61ms
 ✓ components/__tests__/PanelResizeHandle.test.tsx > PanelResizeHandle > writes the latest width once per frame across a longer drag 19ms
 ✓ components/__tests__/PanelResizeHandle.test.tsx > PanelResizeHandle > commits the pending width when the pointer comes up, before the drag ends 17ms
 ✓ components/__tests__/PanelResizeHandle.test.tsx > PanelResizeHandle > commits the pending width on a document-level pointer cancel 16ms
 ✓ components/__tests__/PanelResizeHandle.test.tsx > PanelResizeHandle > commits the pending width when the panel closes mid-drag 14ms
 ✓ components/__tests__/PanelResizeHandle.test.tsx > PanelResizeHandle > still clamps the width to the panel's limits 14ms
 ✓ __tests__/useCopilotPage-store-selectors.test.tsx > useCopilotPage — copilot UI store reads > does not re-render for writes to fields it does not read 13ms
 ✓ __tests__/useCopilotPage-store-selectors.test.tsx > useCopilotPage — copilot UI store reads > still re-renders when the model or dry-run setting changes 4ms
 ✓ __tests__/store-selectors.test.tsx > copilot UI store readers > useWorkflowImportAutoSubmit ignores unrelated writes 14ms
 ✓ __tests__/store-selectors.test.tsx > copilot UI store readers > useSessionDeletion ignores unrelated writes and follows sessionToDelete 7ms
 ✓ __tests__/store-selectors.test.tsx > copilot UI store readers > useLockedTierFallback ignores unrelated writes 3ms
 ✓ __tests__/store-selectors.test.tsx > copilot UI store readers > useConnectionPicker ignores unrelated writes and follows the model 19ms
 ✓ __tests__/store-selectors.test.tsx > copilot UI store readers > useChatInput ignores unrelated writes and still picks up an initial prompt 5ms
 ✓ __tests__/store-selectors.test.tsx > copilot UI store readers > NotificationToggle ignores unrelated writes 45ms
 ✓ __tests__/store-selectors.test.tsx > copilot UI store readers > NotificationDialog ignores unrelated writes 89ms
 ✓ components/ChatSidebar/__tests__/ChatSidebar.test.tsx > ChatSidebar — copilot UI store reads > does not re-render while a panel resize writes its width 120ms
 Test Files  4 passed (4)
      Tests  16 passed | 19 skipped (35)
```

Full copilot + perf suites: `pnpm vitest run src/app/(platform)/copilot
scripts/perf`: **306 files, 4027 passed** (4 expected-fail). `pnpm
format`, `pnpm lint` (only a pre-existing warning in
`CredentialMentionMarkdown.tsx`) and `pnpm types` are clean. Production
`pnpm build` succeeds, and `grep -r __renderCounter .next/static` finds
nothing.

### Hand-off / follow-ups (out of scope)

- **ChatSidebar during a streamed turn** still renders once (`state#46`,
its session-list query), and session rows redraw. That's SECRT-3008.
- `ContextPanel`'s per-frame render still re-renders its empty state
(`PanelEmptyState` → `Icon`, `Text`, `Dialog`) about 60 times in a drag.
Memoising the panel content would cut the remaining ~2k renders, but it
isn't needed for this ticket.
- Shared files touched, append-only:
`scripts/perf/scenarios/registry.ts` (one import + one entry at the
end), `docs/engineering/frontend-perf-baseline.md` (new section at the
end), `docs/engineering/frontend-perf-measurement.md` (one table row).

### Agents and large language models used

- Claude Code (Conductor) 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] New Vitest tests fail without the fix and pass with it (selectors:
unrelated writes do not re-render; related writes still do. Resize: 10
moves in one frame produce one write; commit on pointer
up/cancel/unmount; clamping unchanged)
  - [x] Full copilot + perf Vitest suites pass, plus format/lint/types
- [x] `pnpm perf` before/after for `copilot-resize-drag`,
`copilot-background-stream` and `copilot-stream-reply` (alternated 5 +
5)
- [x] Real app on :3000: drag resize (paced, burst, past the cap),
persisted width across a reload, sidebar delete dialog, notification
popover, and send plus a streamed reply. Identical on base and branch

#### 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**). No configuration changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-10-19 20:05:11 +00:00
Abhimanyu YadavandClaude Opus 5.5 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 | a1578ad9a | 49 | 183,145 | 3,737.7 | 5,448 | 20 | 96.6 |
| before | 2 | a1578ad9a | 45 | 183,109 | 4,069.1 | 5,719 | 20 | 89.2 |
| before | 3 | a1578ad9a | 45 | 183,109 | 4,069.1 | 5,475 | 20 | 91.8 |
| after | 1 | a1578ad9a | 5 | 429 | 85.8 | 46 | 0 | 85.1 |
| after | 2 | a1578ad9a | 5 | 429 | 85.8 | 38 | 0 | 85.1 |
| after | 3 | a1578ad9a | 5 | 429 | 85.8 | 41 | 0 | 86.6 |

</details>

Total renders drop from 183,109 to 429. The 429 are exactly what the
idle chat renders in 20 s (see `copilot-idle` below), so the transcript
does no work at all while the tool runs. The 20 long tasks (one per
tick) drop to 0.

#### `copilot-stream-reply`: alternated, 8 runs per side (3 + 5, base
files checked out in between)

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 168 | 190 | +22 (+13.1%) |
| total renders | 409,391 | 406,814 | -2,577 (-0.6%) |
| renders per commit | 2,442.8 | 2,143.1 | -299.7 (-12.3%) |
| max renders in one commit | 9,298 | 9,298 | 0 (0.0%) |
| script time (ms) | 9,633 | 9,564 | -68 (-0.7%) |
| task time (ms) | 10,077 | 10,054 | -23 (-0.2%) |
| layout time (ms) | 36 | 50 | +14 (+40.2%) |
| style recalc time (ms) | 89 | 91 | +2 (+1.8%) |
| long tasks (≥ 50 ms) | 43 | 43 | -1 (-1.2%) |
| long task total (ms) | 9,682 | 9,591 | -90 (-0.9%) |
| heap used after GC (MB) | 98.5 | 94.8 | -3.7 (-3.7%) |

No detectable change. Total renders and script time are within the
run-to-run spread (single runs range 374k–428k on both sides). Ticks
during a streamed reply mostly landed in commits that were rendering
anyway. Commits go up and renders per commit go down, which is React's
frame batching shifting, not extra work.

#### `copilot-idle` (control: no live turn, so no timer before or after)

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 5 | 5 | 0 (0.0%) |
| total renders | 429 | 429 | 0 (0.0%) |
| renders per commit | 85.8 | 85.8 | 0.0 (0.0%) |
| max renders in one commit | 214 | 214 | 0 (0.0%) |
| script time (ms) | 37 | 35 | -2 (-6.2%) |
| task time (ms) | 49 | 40 | -9 (-19.1%) |
| layout time (ms) | 0 | 0 | 0 |
| style recalc time (ms) | 0 | 0 | 0 |
| long tasks (≥ 50 ms) | 0 | 0 | 0 |
| long task total (ms) | 0 | 0 | 0 |
| heap used after GC (MB) | 82.7 | 83.4 | +0.6 (+0.8%) |

#### Thinking wait (negative check: the indicator still counts)

Temporary scenario, not committed: the same chat, a reply that sends
only `start` and then heartbeats, so "Thinking…" stays up for 23 s. One
run per side.

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 58 | 38 | -20 (-34.5%) |
| total renders | 219,412 | 19,589 | -199,823 (-91.1%) |
| renders per commit | 3,783.0 | 515.5 | -3,267.5 (-86.4%) |
| max renders in one commit | 9,293 | 9,293 | 0 (0.0%) |
| script time (ms) | 6,673 | 759 | -5,914 (-88.6%) |
| task time (ms) | 8,443 | 2,615 | -5,828 (-69.0%) |
| layout time (ms) | 5 | 6 | +1 (+17.9%) |
| style recalc time (ms) | 205 | 252 | +47 (+23.0%) |
| long tasks (≥ 50 ms) | 23 | 2 | -21 (-91.3%) |
| long task total (ms) | 6,437 | 697 | -5,740 (-89.2%) |
| heap used after GC (MB) | 102.8 | 88.0 | -14.8 (-14.4%) |

| before (dev) | after (this PR) |
| --- | --- |
| ![Thinking… 28s on
dev](https://github.com/user-attachments/assets/4d6fa7b4-d3d3-446c-8374-f645d56c21f4)
| ![Thinking… 28s with this
PR](https://github.com/user-attachments/assets/cac4df9e-6dfd-4bd0-85f6-a7d263a48642)
|

Both show **"Thinking… 28s"** at the same place. The label is still
counted from the turn's start and still appears only after 20 s. Only
the indicator re-renders now, not the transcript.

#### Screenshots: the `copilot-tool-wait` window (real app on :3000)

| before (dev) | after (this PR) |
| --- | --- |
| ![Running Perf lookup on
dev](https://github.com/user-attachments/assets/f5378e55-8c85-42ca-a39e-00c307957ebd)
| ![Running Perf lookup with this
PR](https://github.com/user-attachments/assets/f832f74e-68b8-4d6e-af7f-3537b7c26272)
|

Same page in both: the open "Running Perf lookup…" tool row, no elapsed
label.

#### Negative check: the finished turn's "Thought for Xs"

The frozen value is covered by `thinkingClock.test.tsx › hands the
finished turn's elapsed time to its stats bar`, which passes on `dev`
and on this branch (it was written first and run against `dev`). In the
perf harness the just-finished reply shows no time label in **either**
version (84 "Thought for" labels on both sides; screenshots below), so
this PR leaves that unchanged.

| before (dev) | after (this PR) |
| --- | --- |
| ![Finished turn on
dev](https://github.com/user-attachments/assets/a72d73ac-b6f8-4070-8be9-df91fc89ec04)
| ![Finished turn with this
PR](https://github.com/user-attachments/assets/de9c1aac-8244-4ed6-b264-9dc04b922a14)
|

#### Targeted tests

```
✓ useElapsedTimer.test.tsx > useElapsedTimer > ticks every running timer from one shared clock, in the same tick 39ms
✓ useElapsedTimer.test.tsx > useElapsedTimer > releases the shared clock once no timer is running 9ms
✓ useElapsedTimer.test.tsx > useElapsedAnchor > holds the run's start without re-rendering every second 5ms
✓ useElapsedTimer.test.tsx > useElapsedAnchor > starts from the server anchor and re-syncs when a later one arrives 7ms
✓ useElapsedTimer.test.tsx > useElapsedAnchor > freezes the elapsed reading when the run stops and keeps it until the next one 10ms
✓ ThinkingIndicator.test.tsx > ThinkingIndicator > shows the elapsed time from startedAtMs only after 20 s 70ms
✓ ThinkingIndicator.test.tsx > ThinkingIndicator > runs no clock while inactive 13ms
✓ ThinkingIndicator.test.tsx > ThinkingIndicator > stops its clock when it unmounts 6ms
✓ ThinkingIndicator.test.tsx > ThinkingIndicator > still shows a parent-owned elapsedSeconds, with no clock of its own 17ms
✓ TurnStatsBar.test.tsx > TurnStatsBar > renders nothing when there is no time, no timestamp, and no counters 29ms
✓ TurnStatsBar.test.tsx > TurnStatsBar > prefers live elapsedSeconds over the persisted durationMs 12ms
✓ TurnStatsBar.test.tsx > TurnStatsBar > uses durationMs when the turn is finalized 6ms
✓ TurnStatsBar.test.tsx > TurnStatsBar > renders nothing for sub-second durations (would round to 0s) 5ms
✓ TurnStatsBar.test.tsx > TurnStatsBar > renders nothing when only a timestamp is present (date is hover-only) 19ms
✓ TurnStatsBar.test.tsx > TurnStatsBar > swaps to the date on hover and reverts on mouse leave 12ms
✓ TurnStatsBar.test.tsx > TurnStatsBar > renders work-done counters from assistant tool parts 8ms
✓ TurnStatsBar.test.tsx > TurnStatsBar — live elapsed timer > ticks once per second while running so the label reflects real time 11ms
✓ TurnStatsBar.test.tsx > TurnStatsBar — live elapsed timer > freezes the displayed elapsed time when isRunning flips to false 10ms
✓ TurnStatsBar.test.tsx > TurnStatsBar — live elapsed timer > anchors to an ISO timestamp so a fresh mount reflects real elapsed time 8ms
✓ TurnStatsBar.test.tsx > TurnStatsBar — live elapsed timer > clamps a future-dated anchor to zero rather than a negative reading 6ms
✓ TurnStatsBar.test.tsx > TurnStatsBar — live elapsed timer > falls back to mount-time counting when the anchor is invalid 7ms
✓ TurnStatsBar.test.tsx > TurnStatsBar — live elapsed timer > re-syncs when a late-arriving anchor replaces the previous one mid-run 8ms
✓ useNowSecond.test.tsx > useNowSecond > gives every subscriber the same second, in the same tick 22ms
✓ useNowSecond.test.tsx > useNowSecond > ticks on the wall-clock second boundary, once a second 3ms
✓ useNowSecond.test.tsx > useNowSecond > stops the timer when the last subscriber unmounts 3ms
✓ useNowSecond.test.tsx > useNowSecond > restarts from the current second for a new subscriber 2ms
✓ useNowSecond.test.tsx > useNowSecond > neither subscribes nor re-renders while disabled 2ms
✓ useNowSecond.test.tsx > useNowMinute > ticks once a minute on the wall-clock minute, shared by every subscriber 2ms
✓ thinkingClock.test.tsx > ChatMessagesContainer — thinking clock > does not re-render the transcript every second while a tool runs 58ms
✓ thinkingClock.test.tsx > ChatMessagesContainer — thinking clock > shows the elapsed time in the thinking indicator after 20 s, without re-rendering the transcript 46ms
✓ thinkingClock.test.tsx > ChatMessagesContainer — thinking clock > hands the finished turn's elapsed time to its stats bar 13ms
 Test Files  7 passed (7)
      Tests  132 passed (132)
```

On `dev`, `thinkingClock.test.tsx` fails as expected: the transcript
re-renders every second (`expected 12 to be 2`, `expected 11 to be 3`).
The freeze test passes on both.

Full run of `src/hooks`, `src/app/(platform)/copilot`,
`src/app/(public)/tour` and `scripts/perf`: 316 files, 4,054 passed, 4
expected-fail. `pnpm format`, `pnpm lint` and `pnpm types` are clean.

### Hand-off

- **Out of scope here, follow-ups:** move
`TurnPausedCard/LivePausedCard/useNow.ts` (one 1 s clock per paused
card) and `pro-activation/useQuoteExpiry.ts` onto `useNowSecond`. They
are in the Linear issue but not in this workspace's file list.
`useNowMinute` is ready for the library relative-time labels
(SECRT-3010). SECRT-3005 (memoized `MessageRow`) can build on this,
since the transcript no longer has a per-second state cause.
- **Pre-existing, unchanged:** `ChatMessagesContainer` is not keyed by
session, so the frozen "Thought for" value outlives a thread switch
until the next turn starts, just as `frozenElapsedRef` did. In the perf
harness the just-finished turn shows no time label at all, before or
after; worth a look separately.
- **Harness note:** with `pnpm dev` on 16 GB, the dev server grew past 9
GB across the code swaps and was OOM-killed once. Under that pressure
`CopilotPage.dismissNotificationPrompt` sometimes timed out clicking
"Not now" (3 s). In a later 20-attempt alternation (10 base, 10 fix) on
a fresh dev server, every attempt passed.

### Agents and large language models used

- Claude Code (Conductor) 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] `copilot-tool-wait` before/after with `--runs 3 --screenshot`:
renders 183,109 → 429, long tasks 20 → 0
- [x] `copilot-stream-reply` alternated, 8 runs per side: no regression
  - [x] `copilot-idle` control unchanged
- [x] Real app: "Thinking… 28s" still shows and counts during a
token-less wait, on dev and with this PR
- [x] Vitest: shared ticker (same value in the same tick, one timer,
stops on last unmount), ThinkingIndicator 20 s threshold, transcript
does not re-render per second, frozen elapsed handed to TurnStatsBar
- [x] Existing `TurnStatsBar` live-timer tests pass unchanged against
the rebuilt `useElapsedTimer`

#### 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/106)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-10-19 18:48:27 +00:00
Nicholas Tindleandgpt-6-astra 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 b729e9b: all 52 checks passed, one skipped, including
the required Check PR Status; backend patch coverage is 93.82%. Native
scoped checks also passed. The full local pre-commit run remains
unavailable because Bash hooks hit Windows/WSL worktree/tool-resolution
errors, the isort hook's Poetry option is unsupported locally, and the
unscoped typecheck scanned Classic with missing dependencies.
- [ ] Full application chat/tool/Dream E2E and repeated
production-representative no-regression evaluation remain unrun.

<details>
  <summary>Test plan scope and reproduction</summary>

Run `poetry run pytest
backend/copilot/graphiti/extraction_prompts_test.py
backend/copilot/graphiti/llm_client_test.py
backend/copilot/graphiti/flex_client_test.py` from the backend
directory. For the paid regression, configure a test FalkorDB and
model/embedding credentials, set `RUN_GRAPHITI_LLM_EVAL=1` and
`GRAPHITI_LLM_MODEL=gpt-6-luna`, and run `poetry run pytest
backend/copilot/graphiti/extraction_quality_integration_test.py -q`. The
fixture uses a fresh graph. The full component evaluation's methodology,
raw/adjudicated counts, and limits are in the report.

</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**)

<details>
  <summary>Configuration compatibility</summary>

No deployment configuration changes. `RUN_GRAPHITI_LLM_EVAL` opts into
paid tests only; it is not a runtime setting.

</details>

Co-authored-by: gpt-6-astra (Codex) <[email protected]>

---------

Co-authored-by: gpt-6-astra (Codex) <[email protected]>
2026-10-19 17:32:29 +00:00