Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
4efe0d1
test(webapp): pin cross-tenant isolation for the chat store
kathiekiwi Aug 10, 2026
34996fd
test(webapp): pin chat.agent turn durability across crash and resume
kathiekiwi Aug 10, 2026
097bb5d
feat(webapp,dashboard-agent-db): server-side agent message quota
kathiekiwi Aug 10, 2026
c42002b
Merge remote-tracking branch 'origin/feat/query-safety-tri-11165' int…
kathiekiwi Aug 10, 2026
af86a21
Merge remote-tracking branch 'origin/test/chat-agent-durability-tri-1…
kathiekiwi Aug 10, 2026
b9610f8
Merge remote-tracking branch 'origin/feat/query-safety-tri-11165' int…
kathiekiwi Aug 10, 2026
50ccb2a
fix(webapp): the quota limit shows a friendly upgrade prompt, not the…
kathiekiwi Aug 10, 2026
b1463c2
merge: propagate review fixes from feat/query-safety-tri-11165
kathiekiwi Aug 10, 2026
c9bbe89
merge: propagate review fixes from test/chat-agent-durability-tri-11166
kathiekiwi Aug 10, 2026
6db1133
merge: propagate wave-2 review fixes from feat/query-safety-tri-11165
kathiekiwi Aug 10, 2026
8341730
merge: propagate wave-2 review fixes from test/chat-agent-durability-…
kathiekiwi Aug 10, 2026
d7321e5
merge: propagate org-purge best-effort from feat/query-safety-tri-11165
kathiekiwi Aug 10, 2026
82035a7
merge: propagate org-purge best-effort from test/chat-agent-durabilit…
kathiekiwi Aug 10, 2026
12785db
fix(webapp): only charge agent message quota on a delivered send
kathiekiwi Aug 10, 2026
423d0a9
fix(webapp): guard capped agent send paths and settle the quota re-read
kathiekiwi Aug 10, 2026
bb381d3
merge: quota delivered-send charge review-comment fixes
kathiekiwi Aug 10, 2026
f06235c
merge: propagate review-comment fixes from feat/query-safety-tri-11165
kathiekiwi Aug 10, 2026
08f9d78
merge: propagate review-comment fixes from test/chat-agent-durability…
kathiekiwi Aug 10, 2026
074da82
merge: propagate second-pass fixes from feat/query-safety-tri-11165
kathiekiwi Aug 11, 2026
ebc7e88
merge: propagate second-pass fixes from test/chat-agent-durability-tr…
kathiekiwi Aug 11, 2026
57a84f1
chore(server-changes): consolidate the agent message-quota notes into…
kathiekiwi Aug 11, 2026
19b49c1
fix(webapp): don't charge the message quota for a retry/regenerate
kathiekiwi Aug 11, 2026
4cc2f30
merge: consolidate message-quota notes 2 to 1
kathiekiwi Aug 11, 2026
83f9817
merge: don't charge quota for retry/regenerate
kathiekiwi Aug 11, 2026
c93380e
merge: propagate server-changes consolidation from feat/query-safety-…
kathiekiwi Aug 11, 2026
7c59b8e
merge: propagate server-changes consolidation from test/chat-agent-du…
kathiekiwi Aug 11, 2026
08ee567
merge: propagate changeset consolidation and note restoration from fe…
kathiekiwi Aug 11, 2026
ed572f3
merge: propagate changeset consolidation and note restoration from te…
kathiekiwi Aug 11, 2026
112b837
merge: propagate base UI relocation + drizzle attribution
kathiekiwi Aug 11, 2026
462360d
merge: propagate base UI relocation + drizzle attribution
kathiekiwi Aug 11, 2026
a036345
merge: propagate the tsql linter test fix from feat/query-safety-tri-…
kathiekiwi Aug 11, 2026
289fe1b
merge: propagate tsql linter test fix
kathiekiwi Aug 11, 2026
873cebc
merge: propagate card-test relocation
kathiekiwi Aug 11, 2026
a9c15bf
merge: propagate card-test relocation
kathiekiwi Aug 11, 2026
7428874
chore: merge feat/query-safety-tri-11165 (main sync)
kathiekiwi Aug 11, 2026
d9cfca9
chore: merge test/chat-agent-durability-tri-11166 (main sync)
kathiekiwi Aug 11, 2026
a95e926
fix(webapp): share the message-quota refusal code between server and …
kathiekiwi Aug 11, 2026
bc3e5d0
chore: merge feat/query-safety-tri-11165 (review fixes)
kathiekiwi Aug 11, 2026
3556dff
chore: merge test/chat-agent-durability-tri-11166 (review fixes)
kathiekiwi Aug 11, 2026
06ca8d1
test(webapp): exercise a mid-write turn failure in the durable-resume…
kathiekiwi Aug 11, 2026
4dbb314
chore: merge feat/query-safety-tri-11165 (review fixes round 2)
kathiekiwi Aug 11, 2026
c7e8a4b
chore: merge test/chat-agent-durability-tri-11166 (review fixes round 2)
kathiekiwi Aug 11, 2026
656d607
test(webapp): tear the durable-resume turn with an order-independent …
kathiekiwi Aug 11, 2026
78bf74f
chore: merge feat/query-safety-tri-11165 (review fixes round 3)
kathiekiwi Aug 11, 2026
18c3f25
chore: merge test/chat-agent-durability-tri-11166 (review fixes round 3)
kathiekiwi Aug 11, 2026
8b40a62
chore: merge feat/query-safety-tri-11165 (review fixes round 3)
kathiekiwi Aug 11, 2026
2c0a0b8
chore: merge test/chat-agent-durability-tri-11166 (review fixes round 3)
kathiekiwi Aug 11, 2026
037cd9f
chore: merge feat/query-safety-tri-11165 (composer escape follow-up)
kathiekiwi Aug 11, 2026
36920a9
chore: merge test/chat-agent-durability-tri-11166 (composer escape fo…
kathiekiwi Aug 11, 2026
cab27fd
chore: merge feat/query-safety-tri-11165 (main sync post-#4529)
kathiekiwi Aug 12, 2026
1057049
chore: merge test/chat-agent-durability-tri-11166 (main sync post-#4529)
kathiekiwi Aug 12, 2026
d23718c
chore: merge feat/query-safety-tri-11165 (review fixes)
kathiekiwi Aug 12, 2026
4f4336b
chore: merge test/chat-agent-durability-tri-11166 (review fixes)
kathiekiwi Aug 12, 2026
ce7b6e6
chore: merge feat/query-safety-tri-11165 (prompt re-send guard)
kathiekiwi Aug 12, 2026
3c94fa7
chore: merge test/chat-agent-durability-tri-11166 (prompt re-send guard)
kathiekiwi Aug 12, 2026
315c426
chore: merge feat/query-safety-tri-11165 (main sync post-#4525)
kathiekiwi Aug 12, 2026
a3ed278
chore: merge test/chat-agent-durability-tri-11166 (main sync post-#4525)
kathiekiwi Aug 12, 2026
378e00e
chore: merge feat/query-safety-tri-11165 (demo line-0 guard)
kathiekiwi Aug 12, 2026
6593f88
chore: merge test/chat-agent-durability-tri-11166 (demo line-0 guard)
kathiekiwi Aug 12, 2026
c7bbd1d
chore: merge feat/query-safety-tri-11165 (busy-rejection cap fix)
kathiekiwi Aug 12, 2026
c7c7245
chore: merge test/chat-agent-durability-tri-11166 (busy-rejection cap…
kathiekiwi Aug 12, 2026
e449743
feat(webapp): enforce watch plan limits (#4556)
kathiekiwi Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/agent-message-quota.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

The dashboard agent now comes with a monthly message allowance. A message that fails to send doesn't count against it.
6 changes: 6 additions & 0 deletions .server-changes/agent-watch-plan-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Watches now respect your plan's limits: free plans can run a limited number of watches at once and for a shorter window, with a prompt to upgrade for more.
11 changes: 5 additions & 6 deletions apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Link } from "@remix-run/react";
import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
import { LinkButton } from "~/components/primitives/Buttons";
import { useOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import { v3BillingPath } from "~/utils/pathBuilder";
import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
import { ASK_AGENT_LABEL } from "./agent-identity";
import { messageQuotaReachedCopy } from "./message-quota";

// Matches the composer's outer geometry so the replacement lands in the same place.
const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
Expand All @@ -22,14 +23,12 @@ export function AgentUpgradeBlock({
{context}
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
<div className="flex items-center gap-1.5">
<AgentIcon className={cn("size-4 shrink-0", AGENT_ICON_ACCENT_CLASS)} />
<AgentMonoLogo size={16} decorative className="shrink-0" />
<span className="text-sm font-medium text-text-bright">
Upgrade to unlock {ASK_AGENT_LABEL}
</span>
</div>
<p className="text-xs text-text-dimmed">
You've used all {limit} messages included on the Free plan. Your chats stay here to read.
</p>
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit)}</p>
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
Upgrade
</LinkButton>
Expand Down
33 changes: 27 additions & 6 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentHero } from "./DashboardAgentHero";
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
import { FREE_PLAN_MESSAGE_LIMIT, parseQuotaReachedResponse } from "./message-quota";
import { createTranscriptOrder, orderTranscript } from "./message-order";
import { navigateDestination } from "./navigate-target";
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
Expand Down Expand Up @@ -102,6 +103,9 @@ export function DashboardAgentChat({
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
}) {
const [input, setInput] = useState("");
// Set when the server refuses a send over the cap, so the block shows at once rather than
// waiting for the next quota poll.
const [quotaReached, setQuotaReached] = useState<{ limit: number } | null>(null);
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
Expand All @@ -128,6 +132,18 @@ export function DashboardAgentChat({
.catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR);
}
// Over the message cap: show the upgrade block instead of a generic turn error.
if (res.status === 403) {
const data = (await res
.clone()
.json()
.catch(() => null)) as { error?: string; limit?: number } | null;
const reached = parseQuotaReachedResponse(res.status, data);
if (reached) {
setQuotaReached(reached);
throw new Error("You've reached your message limit.");
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
return res;
},
clientData,
Expand Down Expand Up @@ -185,9 +201,12 @@ export function DashboardAgentChat({
const orderRef = useRef(createTranscriptOrder(initialMessages));
const messages = orderTranscript(rawMessages, orderRef.current);

// Counted here, not in the panel, so it includes the turn just sent.
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
const atMessageCap = quota.kind === "reached";
// Read here, not in the panel, so it re-reads as each turn settles.
const quota = useAgentMessageQuota({ actionPath, chatId, status });
// Either the poll saw the cap, or a send was just refused over it.
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
const messageCapLimit =
quotaReached?.limit ?? (quota.kind === "reached" ? quota.limit : FREE_PLAN_MESSAGE_LIMIT);

const isStreaming = status === "streaming";
// From status, not the last part: the indicator must stay up through silent tool calls.
Expand Down Expand Up @@ -252,6 +271,8 @@ export function DashboardAgentChat({
}, [sendRequest, submit, canSend]);

const retry = useCallback(() => {
// Over the cap, a retry only earns another 403 — same guard as `submit`.
if (atMessageCap) return;
Comment on lines +274 to +275

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Retry button silently does nothing once the allowance is exhausted

The retry action is turned into a no-op whenever the allowance is exhausted (if (atMessageCap) return; at apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx:274-275), even for a re-run that the server does not charge, so clicking Retry on a failed answer appears to do nothing.
Impact: A user whose last answer failed can never recover it once they hit the allowance — the retry button just doesn't respond.

Regenerate is not charged server-side, but is blocked client-side

retryAction can resolve to a regenerate, which the transport sends with trigger: "regenerate-message". The server explicitly does not count that against quota (agentTurnCountsAgainstQuota in apps/webapp/app/services/dashboardAgentQuota.server.ts:97-101), and the .in proxy only returns the 403 refusal when countsAgainstQuota is true (apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts:140-149). So the in-code comment ("a retry only earns another 403") is only true for the resend branch; the regenerate branch would succeed.

Meanwhile the error banner with its Retry button is still rendered at the cap (DashboardAgentChat.tsx:404-414), so the button is visible but inert with no feedback. Gating only the resend branch — or hiding/disabling the retry affordance at the cap — would avoid the dead control.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

// A watch's consent record is a user message nobody typed, so retry never treats it as one.
const action = retryAction(
messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id)))
Expand All @@ -264,7 +285,7 @@ export function DashboardAgentChat({
return;
}
void sendMessage({ text: action.text, messageId: action.messageId });
}, [messages, sendMessage, regenerate, clearError]);
}, [messages, sendMessage, regenerate, clearError, atMessageCap]);

const resolveUri = useTriggerUriResolver(actionPath);

Expand Down Expand Up @@ -414,9 +435,9 @@ export function DashboardAgentChat({
/>
)}
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
{quota.kind === "reached" ? (
{atMessageCap ? (
<AgentUpgradeBlock
limit={quota.limit}
limit={messageCapLimit}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
Expand Down
61 changes: 41 additions & 20 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useCallback, useMemo, useState } from "react";
import { AgentUpgradeBlock } from "./AgentUpgradeGate";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentHero } from "./DashboardAgentHero";
Expand All @@ -16,6 +17,7 @@ export function DashboardAgentDraft({
pageContext,
promotedPrompt,
watchCard,
capReached,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
Expand All @@ -24,6 +26,7 @@ export function DashboardAgentDraft({
pageContext?: AgentPageContext;
promotedPrompt?: SuggestedPrompt;
watchCard?: React.ReactNode;
capReached?: { limit: number } | null;
}) {
const [input, setInput] = useState("");

Expand All @@ -43,12 +46,14 @@ export function DashboardAgentDraft({

const submit = useCallback(
(text: string) => {
// Suggested prompts reach here via the hero, bypassing the composer's cap guard.
if (capReached) return;
Comment on lines +49 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggested prompt buttons do nothing once the message allowance is used up

The suggested-prompt buttons on the empty-chat screen stay clickable but their click is silently discarded (if (capReached) return; at apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx:50), so clicking one produces no reaction at all.
Impact: A user who has hit the allowance clicks a suggestion and nothing happens, with no explanation.

Hero prompts remain rendered while the composer is replaced

When capReached is set the composer slot is swapped for AgentUpgradeBlock (apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx:65-78), but DashboardAgentHero still renders its prompt buttons wired to onSelect={submit}, and submit now returns early. The same shape exists in apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx:397-403 where the hero is shown for an empty transcript while submit bails on atMessageCap. Disabling/hiding the prompt buttons (or surfacing the upgrade block on click) would make the state legible.

Prompt for agents
In apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx the new capReached guard makes submit a no-op, but DashboardAgentHero still renders clickable suggested-prompt buttons (onSelect={submit}), so clicking one silently does nothing. The same applies to the hero rendered from DashboardAgentChat.tsx when atMessageCap is true and the transcript is empty. Consider passing a disabled/hidden state down to the hero's prompt buttons when the allowance is reached, so the UI matches the upgrade block that replaced the composer.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const trimmed = text.trim();
if (!trimmed) return;
setInput("");
onSubmit(trimmed);
},
[onSubmit]
[onSubmit, capReached]
);

return (
Expand All @@ -57,25 +62,41 @@ export function DashboardAgentDraft({
pageContext={pageContext}
promoted={promotedPrompt}
composer={
<div className="flex w-full flex-col gap-3">
{watchCard}
<DashboardAgentComposer
layout="hero"
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
</div>
capReached ? (
<div className="flex w-full flex-col gap-3">
{watchCard}
<AgentUpgradeBlock
limit={capReached.limit}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
</div>
) : (
<div className="flex w-full flex-col gap-3">
{watchCard}
<DashboardAgentComposer
layout="hero"
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
</div>
)
}
/>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
writeLastChat,
} from "./last-chat-storage";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import { parseQuotaReachedResponse } from "./message-quota";
import { WatchCard } from "./WatchCard";
import { watchDraftFor } from "./watch-card";
import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state";
Expand Down Expand Up @@ -114,6 +115,8 @@ export function DashboardAgentPanel({
// Until the list has arrived, the page load's server count is the better answer.
const [chatsLoaded, setChatsLoaded] = useState(false);
const [active, setActive] = useState<ActiveChat | null>(null);
// A refused `create` over the cap: the draft shows the upgrade block instead of a raw toast.
const [capReached, setCapReached] = useState<{ limit: number } | null>(null);
// Starts true so an `openWith` request waits for the restore instead of racing it.
const [loading, setLoading] = useState(
() => readLastChat(storageKey)?.path === location.pathname
Expand Down Expand Up @@ -260,14 +263,22 @@ export function DashboardAgentPanel({
publicAccessToken?: string;
headStarted?: boolean;
error?: string;
limit?: number;
};
if (seq !== openChatRequestSeq.current) return;
if (!res.ok || !data.chatId || !data.publicAccessToken) {
const reached = parseQuotaReachedResponse(res.status, data);
if (reached) {
setCapReached(reached);
setActive(null);
return;
}
Comment thread
kathiekiwi marked this conversation as resolved.
console.error(`Dashboard agent: failed to create chat (${res.status})`, data.error);
toast.error(data.error ?? "We couldn't start that chat. Try again in a moment.");
setActive(null);
return;
}
setCapReached(null);
setActive({
chatId: data.chatId,
organizationId: organization.id,
Expand Down Expand Up @@ -309,6 +320,7 @@ export function DashboardAgentPanel({
panelOrg.current = organization.id;
claimChatSlot();
setActive(null);
setCapReached(null);
setLoading(false);
setChats([]);
setChatsLoaded(false);
Expand Down Expand Up @@ -634,6 +646,7 @@ export function DashboardAgentPanel({
pageContext={pageContext}
promotedPrompt={promotedPrompt}
watchCard={watchCardElement}
capReached={capReached}
/>
)}
</AgentPanelColumn>
Expand Down
40 changes: 39 additions & 1 deletion apps/webapp/app/components/dashboard-agent/message-quota.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { countUserMessages, FREE_PLAN_MESSAGE_LIMIT, resolveMessageQuota } from "./message-quota";
import {
countUserMessages,
FREE_PLAN_MESSAGE_LIMIT,
MESSAGE_QUOTA_REACHED_ERROR,
messageQuotaReachedCopy,
parseQuotaReachedResponse,
resolveMessageQuota,
} from "./message-quota";

describe("resolveMessageQuota", () => {
it("caps a Free plan at the limit", () => {
Expand Down Expand Up @@ -42,6 +49,37 @@ describe("resolveMessageQuota", () => {
});
});

describe("parseQuotaReachedResponse", () => {
it("maps a create/in 403 cap body to the limit", () => {
// Both the create path and the `in` transport refuse with this exact body.
expect(
parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR, limit: 20 })
).toEqual({ limit: 20 });
});

it("falls back to the free limit when the body omits it", () => {
expect(parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR })).toEqual({
limit: FREE_PLAN_MESSAGE_LIMIT,
});
});

it("ignores other errors and non-403 statuses so they surface normally", () => {
expect(parseQuotaReachedResponse(403, { error: "something_else" })).toBeNull();
expect(parseQuotaReachedResponse(500, { error: MESSAGE_QUOTA_REACHED_ERROR })).toBeNull();
expect(parseQuotaReachedResponse(403, null)).toBeNull();
});
});

describe("messageQuotaReachedCopy", () => {
it("is a friendly sentence naming the limit, never the raw code", () => {
const copy = messageQuotaReachedCopy(20);
expect(copy).toContain("all 20 messages");
expect(copy).toContain("Free plan");
// Control break: if the mapping leaked the server code, this fails.
expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR);
});
});

describe("countUserMessages", () => {
it("counts only what the user sent", () => {
expect(
Expand Down
22 changes: 22 additions & 0 deletions apps/webapp/app/components/dashboard-agent/message-quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ export function resolveMessageQuota({
: { kind: "within", used, limit, remaining };
}

// The server code both the create and `in` paths refuse with. The client owns the copy,
// so this code must never reach the UI as text.
export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached";

// Maps a 403 refusal body to the cap signal, or null for any other error. Both paths use
// this so a `message_quota_reached` code routes to the upgrade block, never a raw toast.
export function parseQuotaReachedResponse(
status: number,
data: { error?: string; limit?: number } | null | undefined
): { limit: number } | null {
if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) {
return { limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT };
}
return null;
}

// The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw
// server code can never be what the user reads.
export function messageQuotaReachedCopy(limit: number): string {
return `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`;
}
Comment on lines +46 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Upgrade copy hard-codes "Free plan" but the server refuses any org over its plan limit

resolveAgentMessageQuota reads the agentMessages limit for every org, not just free ones, so once the cloud side ships a number for paid plans a paying org that exhausts its allowance also gets 403 message_quota_reached. The client renders that through AgentUpgradeBlock, whose heading is "Upgrade to unlock …" and whose body is messageQuotaReachedCopy (apps/webapp/app/components/dashboard-agent/message-quota.ts:48-50) — "You've used all N messages included on the Free plan." A paying customer would be shown an upsell and an incorrect plan name. Consider having the server say which plan the limit came from, or making the copy plan-neutral when the user isn't on the free plan.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// A watch's consent record is a user message the person never typed, so it is
// excluded here exactly as the stored count excludes it.
export function countUserMessages(messages: { role: string; id?: string }[]): number {
Expand Down
Loading
Loading