Skip to content

Commit 4b307dd

Browse files
committed
fix(webapp): honor a zero message allowance and prefer the server-resolved limit over the client constant
1 parent 1b3ba21 commit 4b307dd

7 files changed

Lines changed: 118 additions & 12 deletions

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export function DashboardAgentChat({
206206
// Either the poll saw the cap, or a send was just refused over it.
207207
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
208208
const messageCapLimit =
209-
quotaReached?.limit ?? (quota.kind === "reached" ? quota.limit : FREE_PLAN_MESSAGE_LIMIT);
209+
quotaReached?.limit ?? (quota.kind === "unlimited" ? FREE_PLAN_MESSAGE_LIMIT : quota.limit);
210210

211211
const isStreaming = status === "streaming";
212212
// From status, not the last part: the indicator must stay up through silent tool calls.

apps/webapp/app/components/dashboard-agent/message-quota.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,51 @@ import {
55
MESSAGE_QUOTA_REACHED_ERROR,
66
messageQuotaReachedCopy,
77
parseQuotaReachedResponse,
8+
quotaResponseUpdate,
9+
resolveMessageLimit,
810
resolveMessageQuota,
911
} from "./message-quota";
1012

13+
describe("quotaResponseUpdate", () => {
14+
it("takes both fields from a coherent body", () => {
15+
expect(quotaResponseUpdate({ used: 30, limit: 50 })).toEqual({ used: 30, limit: 50 });
16+
expect(quotaResponseUpdate({ used: 30, limit: null })).toEqual({ used: 30, limit: null });
17+
expect(quotaResponseUpdate({ used: 0, limit: 0 })).toEqual({ used: 0, limit: 0 });
18+
});
19+
20+
it("changes nothing on a degraded body", () => {
21+
// Control break: apply `{}` field-by-field and a good {used:30, limit:50} read decays to
22+
// used 30 against the client's 20 — "reached" against a cap the server never set.
23+
expect(quotaResponseUpdate({})).toBeNull();
24+
expect(quotaResponseUpdate(null)).toBeNull();
25+
expect(quotaResponseUpdate({ limit: 50 })).toBeNull();
26+
});
27+
});
28+
29+
describe("resolveMessageLimit", () => {
30+
it("prefers a finite server-resolved limit over the client constant", () => {
31+
expect(resolveMessageLimit(5)).toBe(5);
32+
expect(resolveMessageLimit(0)).toBe(0);
33+
expect(resolveMessageLimit(500)).toBe(500);
34+
});
35+
36+
it("keeps the free-plan nudge when the server has no finite limit", () => {
37+
// Pre-P0 the server limit is the unlimited sentinel and is sent as null: the client's
38+
// own 20 IS the nudge. Control break: thread the server number here and it disappears.
39+
expect(resolveMessageLimit(null)).toBe(FREE_PLAN_MESSAGE_LIMIT);
40+
expect(resolveMessageLimit(undefined)).toBe(FREE_PLAN_MESSAGE_LIMIT);
41+
});
42+
43+
it("caps against the server limit once it is known", () => {
44+
expect(
45+
resolveMessageQuota({ isFreePlan: true, used: 5, limit: resolveMessageLimit(5) })
46+
).toMatchObject({ kind: "reached", limit: 5 });
47+
expect(
48+
resolveMessageQuota({ isFreePlan: true, used: 5, limit: resolveMessageLimit(null) })
49+
).toMatchObject({ kind: "within", limit: FREE_PLAN_MESSAGE_LIMIT, remaining: 15 });
50+
});
51+
});
52+
1153
describe("resolveMessageQuota", () => {
1254
it("caps a Free plan at the limit", () => {
1355
expect(resolveMessageQuota({ isFreePlan: true, used: 0 })).toEqual({

apps/webapp/app/components/dashboard-agent/message-quota.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts";
44
// would reset.
55
export const FREE_PLAN_MESSAGE_LIMIT = 20;
66

7+
/**
8+
* The cap to show: the plan limit the server resolved, when it resolved a finite one. The
9+
* server sends null while no plan limit exists (self-hosted, or before billing carries one),
10+
* and then the free-plan nudge is the cap — dropping it would remove the nudge entirely.
11+
*/
12+
export function resolveMessageLimit(serverLimit: number | null | undefined): number {
13+
return typeof serverLimit === "number" ? serverLimit : FREE_PLAN_MESSAGE_LIMIT;
14+
}
15+
16+
/**
17+
* What a `?quota=1` body should change, or null for a degraded one. Both fields move together:
18+
* applying a `{}` on top of a good read would keep the count and drop back to the nudge limit,
19+
* which reads as "reached" against a cap the server never set.
20+
*/
21+
export function quotaResponseUpdate(
22+
data: { used?: number; limit?: number | null } | null | undefined
23+
): { used: number; limit: number | null } | null {
24+
if (typeof data?.used !== "number") return null;
25+
return { used: data.used, limit: typeof data.limit === "number" ? data.limit : null };
26+
}
27+
728
export type MessageQuota =
829
| { kind: "unlimited" }
930
| { kind: "within"; used: number; limit: number; remaining: number }

apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useEffect, useRef, useState } from "react";
22
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
3-
import { resolveMessageQuota, type MessageQuota } from "./message-quota";
3+
import {
4+
quotaResponseUpdate,
5+
resolveMessageLimit,
6+
resolveMessageQuota,
7+
type MessageQuota,
8+
} from "./message-quota";
49

510
// Gated on billing PRESENCE, not the plan value: no subscription means billing isn't wired
611
// up (self-hosted), so there is no cap and no upgrade UI. A wired-up, non-paying plan is free.
@@ -24,6 +29,7 @@ export function useAgentMessageQuota({
2429
}): MessageQuota {
2530
const isFreePlan = useIsFreePlan();
2631
const [used, setUsed] = useState<number | undefined>(undefined);
32+
const [serverLimit, setServerLimit] = useState<number | null>(null);
2733

2834
// Bumped each time the status leaves streaming/submitted, which drives the re-read.
2935
const [settleTick, setSettleTick] = useState(0);
@@ -42,14 +48,18 @@ export function useAgentMessageQuota({
4248
try {
4349
const res = await fetch(`${actionPath}?quota=1`, { signal: controller.signal });
4450
if (!res.ok) return;
45-
const data = (await res.json()) as { used?: number };
46-
if (typeof data.used === "number") setUsed(data.used);
51+
const update = quotaResponseUpdate(
52+
(await res.json()) as { used?: number; limit?: number | null }
53+
);
54+
if (!update) return;
55+
setUsed(update.used);
56+
setServerLimit(update.limit);
4757
} catch {
4858
// Leave the count unknown, which means no cap. See `resolveMessageQuota`.
4959
}
5060
})();
5161
return () => controller.abort();
5262
}, [isFreePlan, actionPath, chatId, settleTick]);
5363

54-
return resolveMessageQuota({ isFreePlan, used });
64+
return resolveMessageQuota({ isFreePlan, used, limit: resolveMessageLimit(serverLimit) });
5565
}

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
chatExists,
44
countUnreadWatchWakes,
55
countChatsWithUnreadWork,
6-
getAgentMessageUsage,
76
createChat,
87
getChatMessages,
98
getSession,
@@ -55,9 +54,9 @@ import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.serv
5554
import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server";
5655
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
5756
import {
58-
currentAgentMessagePeriod,
5957
recordAgentMessageSent,
6058
resolveAgentMessageQuota,
59+
UNLIMITED_AGENT_MESSAGES,
6160
} from "~/services/dashboardAgentQuota.server";
6261
import { logger } from "~/services/logger.server";
6362
import { resolveTriggerUri } from "~/services/resolveTriggerUri.server";
@@ -159,11 +158,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
159158

160159
// The per-period counter, org-wide: a deleted chat can't lower it within the period.
161160
if (searchParams.get("quota") === "1") {
162-
const used = await getAgentMessageUsage(dashboardAgentDb, {
161+
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
163162
organizationId: project.organizationId,
164-
period: currentAgentMessagePeriod(),
165163
});
166-
return json({ used });
164+
if (!quota) return json({});
165+
// The sentinel is "no plan limit" — send null so the client keeps its own free-plan nudge
166+
// instead of showing a number nobody would ever reach.
167+
return json({
168+
used: quota.used,
169+
limit: quota.limit < UNLIMITED_AGENT_MESSAGES ? quota.limit : null,
170+
});
167171
}
168172

169173
const chatId = searchParams.get("chatId");

apps/webapp/app/services/dashboardAgentQuota.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
incrementAgentMessageUsage,
55
type DashboardAgentDb,
66
} from "@internal/dashboard-agent-db";
7-
import { getCachedLimit } from "./platform.v3.server";
7+
import { getCachedLimitAllowingZero } from "./platform.v3.server";
88
import { logger } from "./logger.server";
99

1010
// The repo's unlimited sentinel. Never Infinity: it serializes to null in the limit cache.
@@ -44,7 +44,10 @@ export async function resolveAgentMessageQuota(
4444
const readLimit =
4545
params.readLimit ??
4646
(async (organizationId: string) => {
47-
const cached = await getCachedLimit(
47+
// Allowing zero: a plan that includes no messages must cap at 0, not read as absent.
48+
// This call isn't covered directly; the limitValueAllowingZero cases in
49+
// dashboardAgentQuota.test.ts guard the rule it depends on.
50+
const cached = await getCachedLimitAllowingZero(
4851
organizationId,
4952
AGENT_MESSAGE_LIMIT_KEY,
5053
UNLIMITED_AGENT_MESSAGES

apps/webapp/test/dashboardAgentQuota.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resolveAgentMessageQuota,
2020
UNLIMITED_AGENT_MESSAGES,
2121
} from "~/services/dashboardAgentQuota.server";
22+
import { limitValueAllowingZero } from "~/services/platform.v3.server";
2223

2324
/**
2425
* Server-side agent message quota (TRI-12863): a per-(org, period) counter that a deleted chat
@@ -168,6 +169,31 @@ describe("resolveAgentMessageQuota", () => {
168169
}
169170
);
170171

172+
postgresTest(
173+
"a plan allowance of zero caps immediately, it is not read as unlimited",
174+
async ({ prisma, postgresContainer }) => {
175+
const db = await boot(prisma, postgresContainer.getConnectionUri());
176+
const now = new Date();
177+
178+
expect(
179+
await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 0 })
180+
).toEqual({ reached: true, used: 0, limit: 0 });
181+
182+
// The read the default limit path performs. Control break: with the `!result` fallback
183+
// of `getCachedLimit`, a plan value of 0 comes back as the unlimited sentinel.
184+
expect(
185+
limitValueAllowingZero(
186+
{ agentMessages: 0 } as never,
187+
"agentMessages" as never,
188+
UNLIMITED_AGENT_MESSAGES
189+
)
190+
).toBe(0);
191+
expect(
192+
limitValueAllowingZero(undefined, "agentMessages" as never, UNLIMITED_AGENT_MESSAGES)
193+
).toBe(UNLIMITED_AGENT_MESSAGES);
194+
}
195+
);
196+
171197
it("fails open when the counter read throws", async () => {
172198
const throwingDb = {
173199
select: () => {

0 commit comments

Comments
 (0)