Skip to content

Commit bb381d3

Browse files
committed
merge: quota delivered-send charge review-comment fixes
2 parents 82035a7 + 423d0a9 commit bb381d3

5 files changed

Lines changed: 56 additions & 30 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Messages to the dashboard agent that fail to send no longer count against your monthly message allowance. Only delivered messages are counted.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ export function DashboardAgentChat({
201201
const orderRef = useRef(createTranscriptOrder(initialMessages));
202202
const messages = orderTranscript(rawMessages, orderRef.current);
203203

204-
// Counted here, not in the panel, so it includes the turn just sent.
205-
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
204+
// Read here, not in the panel, so it re-reads as each turn settles.
205+
const quota = useAgentMessageQuota({ actionPath, chatId, status });
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 =
@@ -271,6 +271,8 @@ export function DashboardAgentChat({
271271
}, [sendRequest, submit, canSend]);
272272

273273
const retry = useCallback(() => {
274+
// Over the cap, a retry only earns another 403 — same guard as `submit`.
275+
if (atMessageCap) return;
274276
// A watch's consent record is a user message nobody typed, so retry never treats it as one.
275277
const action = retryAction(
276278
messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id)))
@@ -283,7 +285,7 @@ export function DashboardAgentChat({
283285
return;
284286
}
285287
void sendMessage({ text: action.text, messageId: action.messageId });
286-
}, [messages, sendMessage, regenerate, clearError]);
288+
}, [messages, sendMessage, regenerate, clearError, atMessageCap]);
287289

288290
const resolveUri = useTriggerUriResolver(actionPath);
289291

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,14 @@ export function DashboardAgentDraft({
4646

4747
const submit = useCallback(
4848
(text: string) => {
49+
// Suggested prompts reach here via the hero, bypassing the composer's cap guard.
50+
if (capReached) return;
4951
const trimmed = text.trim();
5052
if (!trimmed) return;
5153
setInput("");
5254
onSubmit(trimmed);
5355
},
54-
[onSubmit]
56+
[onSubmit, capReached]
5557
);
5658

5759
return (
@@ -61,16 +63,19 @@ export function DashboardAgentDraft({
6163
promoted={promotedPrompt}
6264
composer={
6365
capReached ? (
64-
<AgentUpgradeBlock
65-
limit={capReached.limit}
66-
context={
67-
<DashboardAgentContextBanner
68-
projectSlug={projectSlug}
69-
environmentSlug={environmentSlug}
70-
currentPage={currentPage}
71-
/>
72-
}
73-
/>
66+
<div className="flex w-full flex-col gap-3">
67+
{watchCard}
68+
<AgentUpgradeBlock
69+
limit={capReached.limit}
70+
context={
71+
<DashboardAgentContextBanner
72+
projectSlug={projectSlug}
73+
environmentSlug={environmentSlug}
74+
currentPage={currentPage}
75+
/>
76+
}
77+
/>
78+
</div>
7479
) : (
7580
<div className="flex w-full flex-col gap-3">
7681
{watchCard}

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import type { UIMessage } from "@ai-sdk/react";
2-
import { useEffect, useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
32
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
4-
import { countUserMessages, resolveMessageQuota, type MessageQuota } from "./message-quota";
3+
import { resolveMessageQuota, type MessageQuota } from "./message-quota";
54

65
// Gated on billing PRESENCE, not the plan value: no subscription means billing isn't wired
76
// up (self-hosted), so there is no cap and no upgrade UI. A wired-up, non-paying plan is free.
@@ -11,20 +10,30 @@ function useIsFreePlan(): boolean | undefined {
1110
return subscription.isPaying === false;
1211
}
1312

14-
// `used` is the server's per-period count for the org. Re-read whenever the user sends, so
15-
// the running total tracks the message just sent without counting the transcript twice.
13+
// `used` is the server's per-period count for the org. Re-read once a turn settles — the
14+
// server increment happens mid-turn in the `.in` proxy, so reading on optimistic append
15+
// would lag the count by one message and show the cap a message late.
1616
export function useAgentMessageQuota({
1717
actionPath,
1818
chatId,
19-
messages,
19+
status,
2020
}: {
2121
actionPath: string;
2222
chatId: string;
23-
messages: UIMessage[];
23+
status: string;
2424
}): MessageQuota {
2525
const isFreePlan = useIsFreePlan();
2626
const [used, setUsed] = useState<number | undefined>(undefined);
27-
const sentCount = countUserMessages(messages);
27+
28+
// Bumped each time the status leaves streaming/submitted, which drives the re-read.
29+
const [settleTick, setSettleTick] = useState(0);
30+
const prevStatus = useRef(status);
31+
useEffect(() => {
32+
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
33+
const nowSettled = status === "ready" || status === "error";
34+
prevStatus.current = status;
35+
if (wasInFlight && nowSettled) setSettleTick((tick) => tick + 1);
36+
}, [status]);
2837

2938
useEffect(() => {
3039
if (isFreePlan !== true) return;
@@ -40,7 +49,7 @@ export function useAgentMessageQuota({
4049
}
4150
})();
4251
return () => controller.abort();
43-
}, [isFreePlan, actionPath, chatId, sentCount]);
52+
}, [isFreePlan, actionPath, chatId, settleTick]);
4453

4554
return resolveMessageQuota({ isFreePlan, used });
4655
}

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
121121
parsed = undefined;
122122
}
123123

124+
// Hoisted so it is visible after the fetch: quota is charged only once the send succeeds.
125+
let countsAgainstQuota = false;
126+
124127
if (parsed) {
125128
// Actions are placed by the server only, and this proxy is the one path a browser
126129
// can reach `.in` through.
@@ -134,7 +137,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
134137
}
135138

136139
// Only a real user message consumes quota; action turns were refused above.
137-
const countsAgainstQuota = agentTurnCountsAgainstQuota(parsed);
140+
countsAgainstQuota = agentTurnCountsAgainstQuota(parsed);
138141
if (countsAgainstQuota) {
139142
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
140143
organizationId: project.organizationId,
@@ -170,12 +173,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
170173
...(repoSnapshot ? { repoSnapshot } : {}),
171174
};
172175
body = JSON.stringify(parsed);
173-
174-
if (countsAgainstQuota) {
175-
await recordAgentMessageSent(dashboardAgentDb, {
176-
organizationId: project.organizationId,
177-
});
178-
}
179176
}
180177
}
181178

@@ -188,6 +185,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
188185
try {
189186
const upstream = await fetch(upstreamUrl, { method: "POST", headers, body });
190187
const text = await upstream.text();
188+
// Charge quota only for a delivered message: a non-2xx upstream (or a throw below)
189+
// must not burn a send that never reached the agent.
190+
if (countsAgainstQuota && upstream.ok) {
191+
await recordAgentMessageSent(dashboardAgentDb, {
192+
organizationId: project.organizationId,
193+
});
194+
}
191195
return new Response(text, {
192196
status: upstream.status,
193197
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },

0 commit comments

Comments
 (0)