Skip to content

feat(webapp): server-side agent message quota - #4552

Merged
kathiekiwi merged 62 commits into
feat/query-safety-tri-11165from
feat/agent-message-quota-tri-12863
Aug 12, 2026
Merged

feat(webapp): server-side agent message quota#4552
kathiekiwi merged 62 commits into
feat/query-safety-tri-11165from
feat/agent-message-quota-tri-12863

Conversation

@kathiekiwi

Copy link
Copy Markdown
Collaborator

What & why

The Free-plan agent-message allowance was only a client-side hint (useIsFreePlan() was hardcoded to undefined, so nothing was enforced) and the running count was a live COUNT(*) over chat_messages — which meant deleting a chat silently freed quota. This PR makes the allowance a real server-side limit with a durable counter, so the cap holds regardless of the client and a deleted chat can't reclaim messages within the period.

The cloud side that fills the actual per-plan number is a separate PR (TRI-12863 P0). Until it deploys the agentMessages limit key is absent, which resolves to the repo's unlimited sentinel — the correct fail-open default, and why this ships independently.

What's inside

  • New counter table trigger_dashboard_agent.agent_message_usage, keyed (organization_id, period) where period is a UTC calendar month "YYYY-MM". FK-free (pgSchema convention), plus a drizzle migration. Deliberately not joined to chats — that closes the delete-a-chat hole. Distinct from TRI-13068's AiUsageEvent; neither is derived from the other.
  • New service dashboardAgentQuota.server.ts: a pure checkAgentMessageQuota({ used, limit }) (so the MCP send path can reuse the rule later) plus an org-scoped resolver that reads the period counter and the cached plan limit, and a recordAgentMessageSent increment.
  • Counting on send: the create path (head start) and the .in append path each increment one user message. The append path counts only after the existing trigger === "action" 403, so wakes never count.
  • Enforcement: at/over the limit both send routes return 403 { error: "message_quota_reached", limit }, which the client renders as AgentUpgradeBlock rather than a generic failure. Never a silent drop.
  • Client: ?quota=1 now reads the period counter; useIsFreePlan() is a real read gated on billing presence (no subscription → self-hosted → no cap, no upgrade UI), not on the plan value.

Key decisions

  • Fails open. The cap is a nudge, not a security boundary. An absent limit (self-hosted, or pre-P0) resolves to the unlimited sentinel 100_000_000 (never Infinity — that serializes to null in the Redis limit cache), and a counter read that throws returns "no cap". Self-hosted needs zero extra branching — it falls out of the fallback, with a test to prove it.
  • Billing-presence gate. The upgrade UI keys off whether a subscription exists, so a self-hosted install with no billing shows no cap and no upsell label.
  • The (org, period) table closes the delete hole. A standalone counter, not a count over chat rows, so deleting a chat can't free quota inside the period.

Testing

apps/webapp/test/dashboardAgentQuota.test.ts (testcontainers, no mocks):

  • pure checkAgentMessageQuota under/at/over/unlimited (control-breaks the >=);
  • agentTurnCountsAgainstQuota counts a message but not a wake (control-breaks the wake exclusion);
  • the counter accumulates and a deleted chat can't free quota within the period; other periods/orgs start fresh;
  • the resolver reports reached over the limit and never reached when unlimited;
  • fails open when the limit is absent (self-hosted) and when the counter read throws.

TRI-12863

A chat/session belongs to one (org, user) pair. Pin that every store read — getChatMessages, getSession, chatExists, listChats, countUserMessages — and appendChatMessageOnce refuse a foreign tenant, so a chatId from another org reads as not-found and never leaks a transcript or the session's public access token. TRI-11166.
The primitive resumes a turn by replaying its snapshot; pin the store seam that replay lands on: a streamed-then-resumed turn is not double-appended, a crash mid-turn keeps the mid-turn append and rebuilds the session cursor, a failed write commits nothing and the retry replays with no loss, and an OOM restart replays idempotently. TRI-11166.
Enforce the Free plan's agent-message allowance on the server, not just as a
client hint. A per-(organizationId, period) counter lives in its own table,
not joined to chats, so deleting a chat can no longer free quota inside the
period. The create path and the .in append path each count one user message
and refuse over the cap with a typed 403 the client renders as the upgrade
block; wakes (action turns) never count. Fails open: an absent limit
(self-hosted, or before the cloud side ships) or a counter read that throws
means no cap. TRI-12863.
@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: e449743

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d3a03c08-7478-4f95-8a7f-82daa1ba6c77

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds monthly, organization-level agent message quotas with UTC billing periods. Stores usage in a new database table with atomic increment queries. Applies quota checks during chat creation and message forwarding, and records counted messages. Updates dashboard agent chat to process quota refusals, refresh usage, and display an upgrade block with the applicable limit. Adds PostgreSQL-backed tests for threshold, period, persistence, resolver, and error-handling behavior.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: server-side enforcement of agent message quotas.
Description check ✅ Passed The description clearly explains the change, rationale, implementation, key decisions, issue reference, and testing, although it omits several template sections.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/agent-message-quota-tri-12863

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@18c3f25

trigger.dev

npm i https://pkg.pr.new/trigger.dev@18c3f25

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@18c3f25

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@18c3f25

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@18c3f25

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@18c3f25

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@18c3f25

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@18c3f25

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@18c3f25

commit: 18c3f25

coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

A failed upstream send (5xx/502) or a non-2xx response burned a quota
message that never reached the agent. Record only after upstream.ok.
Draft submit and chat retry now bail when the message cap is reached, so
a suggested prompt or retry over the cap no longer fires a silent 403.
The capped draft keeps any open watch card. The quota re-reads when a
turn settles instead of on optimistic append, so the count and cap no
longer lag by one message.
devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +49 to +50
// Suggested prompts reach here via the hero, bypassing the composer's cap guard.
if (capReached) return;

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.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +46 to +50
// 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.`;
}

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.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

Open in Devin Review

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

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.

Base automatically changed from test/chat-agent-durability-tri-11166 to feat/query-safety-tri-11165 August 12, 2026 08:03
kathiekiwi and others added 3 commits August 12, 2026 08:20
**What & why.** Watches now honour a plan's watch limits. A watch whose
window exceeds the plan's `agentWatchMaxHours`, or that would push the
org past its `agentWatchers` count, is refused with a new
`watch_limit_reached` result and an upgrade hint (a chat line on the
card, HTTP 409 on the API).

**Key decisions.**
- Plan limits are a floor *below* the existing code ceilings: `min(plan,
WATCH_MAX_HOURS=24)` for the window, and the per-chat cap of 3 still
applies independently. Plans only tighten, never loosen.
- Watcher count is org-wide and checked only after the immediate check
declines, so a one-shot consumes no slot.
- Fails open: an absent limit resolves to the unlimited sentinel, so
self-hosted is unaffected; the upgrade nudge is gated on
`isBillingConfigured()`.
- Follow-up: quiet Pro-mark on the card's long-window options.

TRI-12863
@kathiekiwi
kathiekiwi merged commit 9f67130 into feat/query-safety-tri-11165 Aug 12, 2026
5 of 7 checks passed
@kathiekiwi
kathiekiwi deleted the feat/agent-message-quota-tri-12863 branch August 12, 2026 08:27
kathiekiwi added a commit that referenced this pull request Aug 12, 2026
… — and fixes (#4516)

Plan enforcement for the dashboard agent — message quota and watch
limits — plus the component gallery, fixes and test hardening from the
same stack (#4548, #4549, #4550, #4552, #4556 merged here).

## Plan enforcement
([TRI-12863](https://linear.app/triggerdotdev/issue/TRI-12863))

**Agent message quota.** The Free-plan allowance becomes a real
server-side limit with a durable counter. New `agent_message_usage`
table keyed `(organization_id, period)` — deliberately not joined to
chats, so deleting a chat can't free quota within the period. Both send
paths count one user message (wakes never count) and refuse at the cap
with `403 message_quota_reached`, which the client renders as an upgrade
panel, never a silent drop. The refusal code is a single shared constant
on both sides.

**Watch limits.** A watch whose window exceeds the plan's
`agentWatchMaxHours`, or that would push the org past its
`agentWatchers` count, is refused with `watch_limit_reached` (409 on the
API, an upgrade hint on the card). Plan limits only tighten the existing
code ceilings (`min(plan, 24h)`, per-chat cap of 3 still applies). A
plan limit of zero means zero, not unlimited. Questions answerable
instantly are answered before any plan refusal — a one-shot consumes no
slot and never sees an upgrade nag.

**Fails open by design.** Cloud ships the actual per-plan numbers
separately (TRI-12863 P0). Until then absent limits resolve to the
unlimited sentinel and the upgrade UI is gated on billing presence —
self-hosted sees no cap, no upsell, with tests proving the fallback.
Both quotas are nudges, not security boundaries: a failing limit read
never blocks a send.

## Component gallery

An admin-only gallery of every agent card state: five
`storybook.agent-*` pages (chat UI, view blocks, report, investigation,
watch) with their shared shell and manifest, demo fixtures, two
demo-only cards, toast examples, and the screenshot script. No LLM and
no data — every state renders from fixtures under
`dashboard-agent/demo/`, never reachable from a production path.
Designers and reviewers can look at every state, including the report
states, without seeding anything.

## And fixes

**SDK: watch-mode chat subscriptions survive quiet windows** (TRI-13065,
TRI-13070) — watch mode keeps reconnecting across empty long-poll
windows and only stops on abort or a settled session; a passive
subscriber can no longer stop a turn it doesn't own (`stopOnAbort` is
explicit, default off). Review findings fixed alongside: a superseded
stream's async teardown no longer removes the live successor's abort
controller or multi-tab claim, and stopping a generation hands the chat
back to the user's other tabs.

**Query boundary pinned end-to-end**
([TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165)) — a
route-level test drives `api.v1.query` with a real signed environment
JWT (writes refused before ClickHouse, a read passes); `readonly=1` made
non-overridable; a per-turn cap stops the model burning a turn rewriting
a query it can't fix (deterministic SQL errors only — busy/transport
rejections don't count).

**chat.agent durability regression suite**
([TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166)) —
testcontainers-backed coverage of the two audit criticals (cross-tenant
isolation, no duplicate mid-stream turn, both control-broken) plus
crash-resume, cursor-based refresh, clean rollback of a mid-write turn
failure (torn by a real constraint violation), and OOM-restart replay.

**Investigation sweep backoff** — stale investigations get an attempt
counter and backoff so a poison row can't pin the sweep queue head
(migration `0005`: `sweep_attempts`, `last_sweep_attempt_at`).

## Screenshots

<img width="1440" height="791" alt="Screenshot 2026-08-06 at 00 36 19"
src="https://github.com/user-attachments/assets/6a68cd42-8580-469d-afe7-e28d1eef18e1"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants