fix(frontend): show plan guidance for memory chat paywalls (#16177)

### Why / What / How

Starting a memory chat without the required plan currently shows a retry
error and reports an expected HTTP 402 to Sentry. The memory page is
reachable through settings, so this leaves the user without an upgrade
path.

A 402 from session creation now closes the current panel and shows the
existing “The memory chat needs an active plan” toast. Its “See plans”
action links to billing; installs with billing disabled retain the
existing administrator guidance. The shared `ApiError` classifier uses
the HTTP status, so authorization, rate-limit, service, network, and
malformed-success failures keep the retry flow.

The request generation also guards notifications, and unmounting
invalidates pending creates. A late response from an old scope or a page
the user has left cannot show a global plan toast or disturb the current
chat. A stale request cannot release a newer request's create lock.

### Changes 🏗️

- Adopt the original memory chat paywall fix, preserving both original
commits by `posthog-eu[bot]` and their Claude Code attribution.
- Merge current `dev` normally and preserve its API, provider-routing,
and shared paywall presentation contracts.
- Guard obsolete responses before notification or panel updates,
including when leaving memory settings.
- Replace toast-spy-only assertions with page/component integrations
using generated API hooks, the real request mutator, MSW responses, and
the rendered shared toast and action.
- Add deferred-response regressions for scope changes, unmounting, and
overlapping session creation.

### Agents and large language models used

- Original implementation: Claude Code with Claude Opus 5.5
(`claude-opus-5-5`), through PostHog Desktop.
- Implementation takeover and validation: Codex with GPT-6 Astra
(`gpt-6-astra`), through T3 Code.

### 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] Reproduce missing plan guidance with the current committed `dev`
hook in the page/request integration test.
- [x] Reproduce stale toasts after scope changes and page unmount on the
original PR implementation merged with `dev`.
- [x] Whole-frontend `corepack pnpm format`, `corepack pnpm lint`, and
`corepack pnpm types`, in order.
- [x] Memory page and request-mutator regression suites: 100 tests in 10
files pass, with at most two Vitest workers.

Validation covers the component/request boundary with MSW and mock
Next.js navigation. No browser, live-backend billing, or checkout
journey was run. Whole-frontend lint passes with six existing
hook-naming warnings in unchanged files.

#### For configuration changes:

- [x] `.env.default` is already compatible with my changes
- [x] `docker-compose.yml` is already compatible with my changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

No configuration changes.

<!-- posthog-self-driving-origin:01a172df-27bf-7892-bfe2-16d38808d104
-->
## Origin

- Error tracking: [issue
1](https://eu.posthog.com/project/117338/error-tracking/01a172de-6809-70a3-9de9-99d27420dcad),
[issue
2](https://eu.posthog.com/project/117338/error-tracking/01a177a9-a4f2-77e1-894f-5d96eb415323),
[issue
3](https://eu.posthog.com/project/117338/error-tracking/01a180c8-939d-7522-b9c4-2efd5453e2d9)
- First signal: 2026-10-25
- Inbox report:
[open](https://eu.posthog.com/project/117338/inbox/reports/01a172df-27bf-7892-bfe2-16d38808d104?link_source=github_pr)
- Likely cause:
[`7b847b6`](https://github.com/Significant-Gravitas/AutoGPT/commit/7b847b6699ea7106c1da4422d2731d25a46e8ff8)
- Task started by: auto-start, after the report was rated P3 and ready
to fix
<!-- /posthog-self-driving-origin:01a172df-27bf-7892-bfe2-16d38808d104
-->

---------

Co-authored-by: posthog-eu[bot] <226701856+posthog-eu[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <[email protected]>
Co-authored-by: GPT-6 Astra (Codex) <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-01 22:44:41 +00:00
1 parent 06e0e0967e
commit e681d74b42
4 files changed
+482 -1

No files matched your search

@@ -0,0 +1,129 @@
import { screen, within } from "@/tests/integrations/test-utils";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it } from "vitest";
import { server } from "@/mocks/mock-server";
import {
captureException,
renderMemoryPage,
setupMemoryChat,
waitForSessionCreates,
} from "./chat-test-helpers";
beforeEach(setupMemoryChat);
describe("Memory chat request lifetime", () => {
it.each([402, 503])(
"ignores a late HTTP %s from a previous scope",
async (status) => {
const response = Promise.withResolvers<Response>();
server.use(
http.post("*/api/chat/sessions", () => response.promise, {
once: true,
}),
);
renderMemoryPage();
const user = userEvent.setup();
await user.click(
await screen.findByRole("button", { name: "View my summary" }),
);
await waitForSessionCreates(1);
await user.click(screen.getByRole("button", { name: "Memory scope" }));
await user.click(
await screen.findByRole("menuitem", { name: /Growth Marketer/ }),
);
await user.click(
await screen.findByRole("button", { name: "View Maria's summary" }),
);
expect(await screen.findByText("Your memory summary.")).toBeDefined();
response.resolve(
HttpResponse.json({ detail: "Upgrade to continue." }, { status }),
);
await waitForSessionCreates(0);
const panel = screen.getByRole("complementary", {
name: "Memory chat panel",
});
expect(within(panel).getByText("Maria's memory")).toBeDefined();
expect(within(panel).getByText("Your memory summary.")).toBeDefined();
expect(screen.queryByText("Could not start the memory chat")).toBeNull();
expect(
screen.queryByText("The memory chat needs an active plan"),
).toBeNull();
if (status === 402) expect(captureException).not.toHaveBeenCalled();
},
);
it("ignores a late paywall response after leaving memory settings", async () => {
const response = Promise.withResolvers<Response>();
server.use(http.post("*/api/chat/sessions", () => response.promise));
renderMemoryPage();
const user = userEvent.setup();
await user.click(
await screen.findByRole("button", { name: "View my summary" }),
);
await waitForSessionCreates(1);
await user.click(
screen.getByRole("button", { name: "Leave memory settings" }),
);
response.resolve(HttpResponse.json({}, { status: 402 }));
await waitForSessionCreates(0);
expect(
screen.queryByText("The memory chat needs an active plan"),
).toBeNull();
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
expect(captureException).not.toHaveBeenCalled();
});
it("keeps a newer scope's create locked and handles its own paywall", async () => {
const oldResponse = Promise.withResolvers<Response>();
const newResponse = Promise.withResolvers<Response>();
const bodies: unknown[] = [];
server.use(
http.post("*/api/chat/sessions", async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? oldResponse.promise : newResponse.promise;
}),
);
renderMemoryPage();
const user = userEvent.setup();
await user.click(
await screen.findByRole("button", { name: "View my summary" }),
);
await waitForSessionCreates(1);
await user.click(screen.getByRole("button", { name: "Memory scope" }));
await user.click(
await screen.findByRole("menuitem", { name: /Growth Marketer/ }),
);
await user.click(
await screen.findByRole("button", { name: "View Maria's summary" }),
);
await waitForSessionCreates(2);
oldResponse.resolve(HttpResponse.json({}, { status: 402 }));
await waitForSessionCreates(1);
expect(
screen.queryByText("The memory chat needs an active plan"),
).toBeNull();
await user.click(
screen.getByRole("button", { name: "View Maria's summary" }),
);
await waitForSessionCreates(1);
expect(bodies).toEqual([null, { expert_id: "expert-maria" }]);
newResponse.resolve(HttpResponse.json({}, { status: 402 }));
await waitForSessionCreates(0);
expect(
await screen.findByRole("link", { name: "See plans" }),
).toBeDefined();
expect(
screen.queryByRole("complementary", { name: "Memory chat panel" }),
).toBeNull();
expect(captureException).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
import { screen, waitFor } from "@/tests/integrations/test-utils";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it } from "vitest";
import { server } from "@/mocks/mock-server";
import {
captureException,
memoryChatTestConfig,
renderMemoryPage,
setupMemoryChat,
} from "./chat-test-helpers";
beforeEach(setupMemoryChat);
async function openSummaryChat() {
renderMemoryPage();
await userEvent
.setup()
.click(await screen.findByRole("button", { name: "View my summary" }));
}
describe("Memory chat start", () => {
it.each([
[
"a subscription error",
() =>
HttpResponse.json({ detail: "Upgrade to continue." }, { status: 402 }),
],
["a null body", () => HttpResponse.json(null, { status: 402 })],
[
"structured detail",
() =>
HttpResponse.json(
{ detail: { message: "<script>unexpected()</script>" } },
{ status: 402 },
),
],
[
"invalid JSON",
() =>
new HttpResponse("not json", {
status: 402,
headers: { "Content-Type": "application/json" },
}),
],
])(
"offers plans for a 402 with %s without reporting an exception",
async (_case, response) => {
server.use(http.post("*/api/chat/sessions", response));
await openSummaryChat();
expect(
await screen.findByText("The memory chat needs an active plan"),
).toBeDefined();
expect(screen.getByText("Choose one to continue.")).toBeDefined();
const plans = screen.getByRole("link", { name: "See plans" });
expect(plans.getAttribute("href")).toBe("/settings/billing");
expect(
screen.queryByRole("complementary", { name: "Memory chat panel" }),
).toBeNull();
expect(screen.queryByText("Could not start the memory chat")).toBeNull();
expect(captureException).not.toHaveBeenCalled();
await userEvent.setup().click(plans);
await waitFor(() =>
expect(
screen.queryByText("The memory chat needs an active plan"),
).toBeNull(),
);
},
);
it("provides administrator guidance when billing is disabled", async () => {
memoryChatTestConfig.billingEnabled = false;
server.use(
http.post("*/api/chat/sessions", () =>
HttpResponse.json({}, { status: 402 }),
),
);
await openSummaryChat();
expect(
await screen.findByText("Ask whoever runs this server to set one up."),
).toBeDefined();
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
expect(
screen.queryByRole("complementary", { name: "Memory chat panel" }),
).toBeNull();
expect(captureException).not.toHaveBeenCalled();
});
it.each([401, 403, 404, 409, 422, 429, 500, 503])(
"keeps HTTP %s retryable even when its body mentions a paywall",
async (status) => {
server.use(
http.post("*/api/chat/sessions", () =>
HttpResponse.json(
{
status: 402,
detail: "A subscription is required. Upgrade to continue.",
},
{ status },
),
),
);
await openSummaryChat();
expect(
await screen.findByText("Could not start the memory chat"),
).toBeDefined();
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
expect(captureException).toHaveBeenCalledWith(
expect.objectContaining({ status }),
);
expect(
screen.queryByText("The memory chat needs an active plan"),
).toBeNull();
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
},
);
it("keeps network failures retryable without offering a plan", async () => {
server.use(http.post("*/api/chat/sessions", () => HttpResponse.error()));
await openSummaryChat();
expect(
await screen.findByText("Could not start the memory chat"),
).toBeDefined();
expect(captureException).toHaveBeenCalledWith(expect.any(Error));
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
});
it("does not mistake a malformed success body for a paywall", async () => {
server.use(
http.post("*/api/chat/sessions", () =>
HttpResponse.json({ status: 402 }),
),
);
await openSummaryChat();
expect(
await screen.findByText("Could not start the memory chat"),
).toBeDefined();
expect(captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "Failed to create memory chat session",
}),
);
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
});
it("retries a temporary subscription lookup failure and starts the chat", async () => {
server.use(
http.post(
"*/api/chat/sessions",
() =>
HttpResponse.json(
{
detail:
"Subscription state temporarily unavailable, retry shortly.",
},
{ status: 503 },
),
{ once: true },
),
);
await openSummaryChat();
await userEvent
.setup()
.click(await screen.findByRole("button", { name: "Retry" }));
expect(await screen.findByText("Your memory summary.")).toBeDefined();
expect(screen.queryByText("Could not start the memory chat")).toBeNull();
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
});
});
@@ -0,0 +1,157 @@
import { resetCopilotChatRegistry } from "@/app/(platform)/copilot/copilotChatRegistry";
import { TEST_BACKEND_BASE_URL } from "@/app/(platform)/copilot/__tests__/sse-helpers";
import {
getGetV2GetSessionMockHandler200,
getGetV2GetSessionResponseMock200,
getPostV2CreateSessionMockHandler200,
getPostV2CreateSessionResponseMock200,
} from "@/app/api/__generated__/endpoints/chat/chat.msw";
import {
getListExpertsMockHandler200,
getListExpertsResponseMock200,
} from "@/app/api/__generated__/endpoints/experts/experts.msw";
import {
getGetMyExpertMemoryOverviewMockHandler200,
getGetMyMemoryOverviewMockHandler200,
getListMyExpertMemoryFactsMockHandler200,
getListMyMemoryFactsMockHandler200,
} from "@/app/api/__generated__/endpoints/memory/memory.msw";
import { Toaster } from "@/components/molecules/Toast/toaster";
import { server } from "@/mocks/mock-server";
import {
assistantTextChunks,
streamSseResponse,
} from "@/tests/integrations/copilot-sse";
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
import { useIsMutating } from "@tanstack/react-query";
import { http } from "msw";
import { useState } from "react";
import { expect, vi } from "vitest";
import SettingsMemoryPage from "../page";
const mocks = vi.hoisted(() => ({
captureException: vi.fn(),
config: { billingEnabled: true },
}));
export const captureException = mocks.captureException;
export const memoryChatTestConfig = mocks.config;
vi.mock("@sentry/nextjs", () => ({
captureMessage: vi.fn(),
captureException: mocks.captureException,
addBreadcrumb: vi.fn(),
getTraceData: () => ({}),
}));
vi.mock("@/services/environment", async (importActual) => {
const actual = await importActual<typeof import("@/services/environment")>();
return {
...actual,
environment: {
...actual.environment,
isCloud: () => false,
getAGPTServerBaseUrl: () => TEST_BACKEND_BASE_URL,
},
};
});
vi.mock("@/app/(platform)/copilot/helpers", async (importActual) => {
const actual =
await importActual<typeof import("@/app/(platform)/copilot/helpers")>();
return {
...actual,
getCopilotAuthHeaders: async () => ({ "x-test-auth": "yes" }),
};
});
vi.mock("@/services/feature-flags/use-get-flag", async (importActual) => {
const actual =
await importActual<
typeof import("@/services/feature-flags/use-get-flag")
>();
return {
...actual,
useGetFlag: (flag: string) =>
flag === actual.Flag.ENABLE_PLATFORM_PAYMENT &&
mocks.config.billingEnabled,
};
});
function MemoryPageHarness() {
const [visible, setVisible] = useState(true);
const pending = useIsMutating({ mutationKey: ["postV2CreateSession"] });
return (
<>
{visible ? <SettingsMemoryPage /> : null}
<Toaster />
<span data-testid="pending-session-requests">{pending}</span>
<button onClick={() => setVisible(false)}>Leave memory settings</button>
</>
);
}
export function renderMemoryPage() {
return render(<MemoryPageHarness />);
}
export async function waitForSessionCreates(count: number) {
await waitFor(() =>
expect(screen.getByTestId("pending-session-requests").textContent).toBe(
String(count),
),
);
}
export function setupMemoryChat() {
resetCopilotChatRegistry();
captureException.mockClear();
memoryChatTestConfig.billingEnabled = true;
window.history.replaceState({}, "", "/settings/memory");
const sessionID = "memory-paywall-test-session";
server.use(
getListExpertsMockHandler200([
{
...getListExpertsResponseMock200()[0],
id: "expert-maria",
name: "Maria",
role: "Growth Marketer",
avatar_url: null,
is_archived: false,
},
]),
getListMyMemoryFactsMockHandler200({ expert_id: null, items: [] }),
getGetMyMemoryOverviewMockHandler200({
expert_id: null,
facts: 0,
entities: 0,
episodes: 0,
}),
getListMyExpertMemoryFactsMockHandler200({
expert_id: "expert-maria",
items: [],
}),
getGetMyExpertMemoryOverviewMockHandler200({
expert_id: "expert-maria",
facts: 0,
entities: 0,
episodes: 0,
}),
getPostV2CreateSessionMockHandler200(
getPostV2CreateSessionResponseMock200({ id: sessionID }),
),
getGetV2GetSessionMockHandler200(
getGetV2GetSessionResponseMock200({
id: sessionID,
messages: [],
active_stream: null,
}),
),
http.post(
`${TEST_BACKEND_BASE_URL}/api/chat/sessions/${sessionID}/stream`,
({ request }) =>
streamSseResponse(assistantTextChunks("Your memory summary."), {
abortSignal: request.signal,
}),
),
);
}
@@ -6,6 +6,7 @@ import {
useGetV2GetSession,
usePostV2CreateSession,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { toastIfPlanRequired } from "@/components/contextual/PaywallToast/helpers";
import * as Sentry from "@sentry/nextjs";
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -95,6 +96,12 @@ export function useMemoryChatPanel({ scopeExpertID }: Args) {
// discarded instead of binding its session under the new scope's name.
const scopeRef = useRef(scopeExpertID);
const scopeGenRef = useRef(0);
useEffect(() => {
return () => {
scopeGenRef.current += 1;
};
}, []);
useEffect(() => {
if (scopeRef.current === scopeExpertID) return;
scopeRef.current = scopeExpertID;
@@ -127,8 +134,13 @@ export function useMemoryChatPanel({ scopeExpertID }: Args) {
pendingPromptRef.current = MEMORY_CHAT_PROMPTS[lastSeedRef.current];
setSessionId(response.data.id);
} catch (err) {
if (generation !== scopeGenRef.current) return;
if (toastIfPlanRequired(err, "The memory chat needs an active plan")) {
setIsOpen(false);
return;
}
Sentry.captureException(err);
if (generation === scopeGenRef.current) setStartError(true);
setStartError(true);
} finally {
// Only release the lock if this call still owns it — a superseded
// create must not unlock a newer scope's in-flight create.