From 87723d3ce22b381a8861ba1c98367992a0714879 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:38:07 +0100 Subject: [PATCH] fix(payg): fire the usage-limit modal when an AI agent run hits the limit (#6638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem We're getting 402s when an AI **agent** (chat) run hits the free allowance / spending cap, but the frontend handles them poorly and never pops the usage-limit modal. The agent runs its tool calls **server-side** (loopback HTTP via `PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor that pops the modal for direct calls. It was caught by the generic tool-failure handler and flattened into a `CANNOT_CONTINUE` reason string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result` event, and rendered as a scary chat bubble. This is the same gap the policy auto-run path bridges (#6626) — one layer up. ## Fix **Backend** (`proprietary`) - `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`. - `AiWorkflowService` detects a downstream 401/402 entitlement sentinel in its three tool-exec catch sites (`onToolCall`, `runPlan`, `onConvertMarkdown`) and surfaces the structured code (+ `subscribed`) on the terminal response instead of the raw failure text. - Factored the 401/402 body extraction `PolicyEngine` already had into a shared `DownstreamEntitlementError` util so the two server-side paths can't drift. **Frontend** - New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` + `dispatchPaygLimitReached`) generalises the previously policy-only bridge. Proprietary can't import the saas modal API (layering), so server-side limit hits broadcast a window event the saas `UsageLimitModalHost` opens the modal from. Migrated the policy path onto it. - `ChatContext` fires the matching modal (free → subscribe, subscribed → raise cap) on the limit result **and** on a direct 402, replacing the raw reason with a brief friendly line (`chat.responses.usage_limit_reached`). No Python engine changes — the charge/402 happens on the Java tool endpoint that Java itself calls. ## Test plan - [x] `:proprietary:compileJava` + `spotlessCheck` clean - [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green - [x] eslint, proprietary + saas typechecks clean - [ ] Manual: drive an agent run over the limit → brief line in chat + the right modal (free vs cap) > Note: proprietary test compilation is currently blocked on the pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated, tracked separately); verified locally by temporarily patching it. --- .../model/api/ai/AiWorkflowResponse.java | 15 ++++ .../policy/engine/PolicyEngine.java | 48 +----------- .../service/AiWorkflowService.java | 50 ++++++++++++ .../service/DownstreamEntitlementError.java | 64 +++++++++++++++ .../public/locales/en-GB/translation.toml | 1 + .../components/chat/ChatContext.tsx | 77 +++++++++++++++++-- .../components/policies/usePolicyAutoRun.ts | 13 +--- .../proprietary/services/policyPipeline.ts | 18 ----- .../proprietary/services/usageLimitBridge.ts | 35 +++++++++ .../saas/components/UsageLimitModalHost.tsx | 24 +++--- 10 files changed, 251 insertions(+), 94 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java create mode 100644 frontend/editor/src/proprietary/services/usageLimitBridge.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java index 8f0fc631b..2f088f444 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java @@ -105,4 +105,19 @@ public class AiWorkflowResponse { + " body or via the X-Stirling-Tool-Report header. May be null for tools" + " that produce only a file.") private JsonNode report; + + @Schema( + description = + "Structured error code when a downstream tool call was blocked (e.g." + + " PAYG_LIMIT_REACHED). Lets the client react — such as opening the" + + " usage-limit modal — instead of only seeing a generic failure. Null" + + " for ordinary outcomes.") + private String errorCode; + + @Schema( + description = + "Whether the team is subscribed, carried from a downstream usage-limit response." + + " Selects which limit modal the client shows (free → subscribe," + + " subscribed → raise cap). Null when the downstream body omitted it.") + private Boolean errorSubscribed; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 687c6d1ae..7db5e7a14 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -36,6 +36,7 @@ import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.WaitState; import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.service.DownstreamEntitlementError; /** * Runs pipelines asynchronously as tracked jobs. {@link #submit} returns a run id immediately; the @@ -208,11 +209,11 @@ public class PolicyEngine { // failure. We don't interpret the code here (that would couple this module to the saas // billing layer); we just pass it through for the client to map. Other statuses fall // through to the generic failure below. - String code = extractDownstreamErrorCode(e); + String code = DownstreamEntitlementError.extractCode(e); if (code != null) { log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code); String message = "Usage limit reached"; - run.failWithCode(message, code, extractDownstreamSubscribed(e)); + run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e)); taskManager.setError(runId, message); } else { String message = "Policy run failed: " + e.getMessage(); @@ -302,49 +303,6 @@ public class PolicyEngine { e.getEndpointPath(), e.getReadTimeout().toSeconds()); } - /** Matches the {@code "error":"CODE"} field of a small JSON error body. */ - private static final java.util.regex.Pattern ERROR_CODE_FIELD = - java.util.regex.Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\""); - - /** - * Pull the {@code error} sentinel out of a downstream 401/402 JSON body — e.g. the saas - * EntitlementGuard's {@code {"error":"PAYG_LIMIT_REACHED",...}}. Regex (not a JSON parse) on - * purpose: the body is a small, server-controlled shape and this keeps the proprietary module - * free of any billing-layer coupling. Returns null for other statuses or an unmatched body, in - * which case the caller treats it as a generic failure. - */ - private static String extractDownstreamErrorCode(RestClientResponseException e) { - int status = e.getStatusCode().value(); - if (status != 401 && status != 402) { - return null; - } - String body = e.getResponseBodyAsString(); - if (body == null || body.isBlank()) { - return null; - } - java.util.regex.Matcher m = ERROR_CODE_FIELD.matcher(body); - return m.find() ? m.group(1) : null; - } - - /** Matches the {@code "subscribed":true|false} field of a small JSON error body. */ - private static final java.util.regex.Pattern SUBSCRIBED_FIELD = - java.util.regex.Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)"); - - /** - * Pull the {@code subscribed} flag out of a downstream 401/402 JSON body (present on the saas - * {@code PAYG_LIMIT_REACHED} response). Null when absent — the client then defaults to the - * free-limit modal. Regex for the same dependency-free reason as {@link - * #extractDownstreamErrorCode}. - */ - private static Boolean extractDownstreamSubscribed(RestClientResponseException e) { - String body = e.getResponseBodyAsString(); - if (body == null || body.isBlank()) { - return null; - } - java.util.regex.Matcher m = SUBSCRIBED_FIELD.matcher(body); - return m.find() ? Boolean.valueOf(m.group(1)) : null; - } - /** * MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the * controller audit aspect on request threads). We reuse it to carry the billing identity onto diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 0178e041a..4e6c51531 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -18,6 +18,7 @@ import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.multipart.MultipartFile; import io.github.pixee.security.Filenames; @@ -385,6 +386,13 @@ public class AiWorkflowService { return new WorkflowState.Terminal( cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); } catch (Exception e) { + AiWorkflowResponse limit = paygLimitResponseOrNull(e); + if (limit != null) { + log.info( + "AI markdown conversion blocked by downstream entitlement gate ({})", + limit.getErrorCode()); + return new WorkflowState.Terminal(limit); + } log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e); return new WorkflowState.Terminal( cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); @@ -470,6 +478,14 @@ public class AiWorkflowService { log.error("Tool {} timed out: {}", endpointPath, e.getMessage()); return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e))); } catch (Exception e) { + AiWorkflowResponse limit = paygLimitResponseOrNull(e); + if (limit != null) { + log.info( + "AI workflow tool {} blocked by downstream entitlement gate ({})", + endpointPath, + limit.getErrorCode()); + return new WorkflowState.Terminal(limit); + } log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e); return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e))); } @@ -585,6 +601,13 @@ public class AiWorkflowService { log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason); return new WorkflowState.Terminal(cannotContinue(reason)); } catch (Exception e) { + AiWorkflowResponse limit = paygLimitResponseOrNull(e); + if (limit != null) { + log.info( + "AI workflow plan blocked by downstream entitlement gate ({})", + limit.getErrorCode()); + return new WorkflowState.Terminal(limit); + } log.error("Failed to execute plan: {}", e.getMessage(), e); return new WorkflowState.Terminal( cannotContinue("Plan execution failed: " + e.getMessage())); @@ -722,6 +745,33 @@ public class AiWorkflowService { return response; } + /** + * If {@code e} is a downstream usage-limit block — a 401/402 from a tool call carrying the saas + * EntitlementGuard's {@code error} sentinel — build a terminal response that carries the + * structured code (+ {@code subscribed}) through to the client, so it can pop the matching + * usage-limit modal instead of surfacing the raw "tool failed: 402…" text. Returns null for any + * other failure, so the caller falls back to its normal tool-failure handling. + * + *

The agent's tool calls run server-side (loopback HTTP via {@link PolicyExecutor}), so this + * 402 never reaches the frontend's API-client interceptor that pops the modal for direct calls + * — same gap the policy auto-run path bridges in {@code PolicyEngine}. + */ + private AiWorkflowResponse paygLimitResponseOrNull(Throwable e) { + if (!(e instanceof RestClientResponseException rce)) { + return null; + } + String code = DownstreamEntitlementError.extractCode(rce); + if (code == null) { + return null; + } + AiWorkflowResponse response = new AiWorkflowResponse(); + response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE); + response.setReason("You've reached your current usage limit."); + response.setErrorCode(code); + response.setErrorSubscribed(DownstreamEntitlementError.extractSubscribed(rce)); + return response; + } + /** * Drive the engine's streaming orchestrator endpoint. Progress events are forwarded to {@code * listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java new file mode 100644 index 000000000..52d9500a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.service; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.web.client.RestClientResponseException; + +/** + * Reads the {@code error} sentinel and {@code subscribed} flag out of a downstream 401/402 JSON + * body — e.g. the saas EntitlementGuard's {@code + * {"error":"PAYG_LIMIT_REACHED","subscribed":false}}. + * + *

Server-side run paths (policy auto-run, AI agent workflows) execute tool calls via loopback + * HTTP, so a usage-limit 402 surfaces as a {@link RestClientResponseException} rather than reaching + * the frontend's API-client interceptor. These helpers let those paths pass the structured code + * through to the client, which maps it to the right usage-limit modal instead of showing a generic + * failure. + * + *

Regex (not a JSON parse) on purpose: the body is a small, server-controlled shape and this + * keeps the proprietary module free of any billing-layer (saas) coupling. + */ +public final class DownstreamEntitlementError { + + private DownstreamEntitlementError() {} + + /** Matches the {@code "error":"CODE"} field of a small JSON error body. */ + private static final Pattern ERROR_CODE_FIELD = + Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\""); + + /** Matches the {@code "subscribed":true|false} field of a small JSON error body. */ + private static final Pattern SUBSCRIBED_FIELD = + Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)"); + + /** + * Pull the {@code error} sentinel out of a downstream 401/402 JSON body. Returns null for other + * statuses or an unmatched body, in which case the caller treats it as a generic failure. + */ + public static String extractCode(RestClientResponseException e) { + int status = e.getStatusCode().value(); + if (status != 401 && status != 402) { + return null; + } + String body = e.getResponseBodyAsString(); + if (body == null || body.isBlank()) { + return null; + } + Matcher m = ERROR_CODE_FIELD.matcher(body); + return m.find() ? m.group(1) : null; + } + + /** + * Pull the {@code subscribed} flag out of the body (present on the saas {@code + * PAYG_LIMIT_REACHED}/{@code FEATURE_DEGRADED} responses). Null when absent — the client then + * defaults to the free-limit modal. + */ + public static Boolean extractSubscribed(RestClientResponseException e) { + String body = e.getResponseBodyAsString(); + if (body == null || body.isBlank()) { + return null; + } + Matcher m = SUBSCRIBED_FIELD.matcher(body); + return m.find() ? Boolean.valueOf(m.group(1)) : null; + } +} diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index c8a1d5d28..7d0e368b2 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2740,6 +2740,7 @@ need_clarification = "Could you clarify your request?" not_found = "I couldn't find the requested information." processing = "Processing ({{outcome}})..." unsupported_capability = "Unsupported capability: {{capability}}" +usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going." [chat.toolsUsed] summary = "Ran {{count}} tools" diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index ac3aad972..05577885b 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -12,6 +12,7 @@ import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; import apiClient from "@app/services/apiClient"; import { getApiBaseUrl } from "@app/services/apiClientConfig"; import { getAuthHeaders } from "@app/services/apiClientSetup"; +import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import { createChildStub } from "@app/contexts/file/fileActions"; import { createNewStirlingFileStub, @@ -179,6 +180,25 @@ interface AiWorkflowResponse { fileId?: string; fileName?: string; contentType?: string; + /** + * Structured error code when a tool call inside the workflow was blocked (e.g. + * PAYG_LIMIT_REACHED / FEATURE_DEGRADED). Present instead of a raw failure reason so the + * client can pop the usage-limit modal. See {@link isPaygLimitCode}. + */ + errorCode?: string; + /** From the blocking 402: true → over spending cap, false/absent → free allowance spent. */ + errorSubscribed?: boolean; +} + +/** + * Usage-limit sentinels the agent can surface (matching the saas EntitlementGuard / apiClient + * interceptor). When one of these is the result's errorCode, we open the usage-limit modal rather + * than render the failure as chat text. + */ +const PAYG_LIMIT_CODES = new Set(["PAYG_LIMIT_REACHED", "FEATURE_DEGRADED"]); + +function isPaygLimitCode(code: string | null | undefined): boolean { + return code != null && PAYG_LIMIT_CODES.has(code); } interface ChatState { @@ -512,18 +532,46 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (!response.ok) { let detail: string | undefined; + let limitHandled = false; try { const body = await response.json(); - detail = - body?.message ?? - body?.detail ?? - body?.error ?? - (Array.isArray(body?.errors) - ? body.errors[0]?.message - : undefined); + const code = typeof body?.error === "string" ? body.error : null; + // A 402 carrying a usage-limit sentinel means the agent call itself was gated. + // Fire the usage-limit modal (free → subscribe, subscribed → raise cap) and show a + // brief line below — not a generic "engine failed" error. + if (response.status === 402 && isPaygLimitCode(code)) { + dispatchPaygLimitReached( + typeof body?.subscribed === "boolean" ? body.subscribed : null, + ); + limitHandled = true; + } else { + detail = + body?.message ?? + body?.detail ?? + body?.error ?? + (Array.isArray(body?.errors) + ? body.errors[0]?.message + : undefined); + } } catch { // non-JSON body — ignore } + if (limitHandled) { + dispatch({ type: "SET_PROGRESS", progress: null }); + dispatch({ + type: "ADD_MESSAGE", + message: { + id: generateId(), + role: ChatRole.ASSISTANT, + content: t( + "chat.responses.usage_limit_reached", + "You've reached your usage limit. Check your plan options to keep going.", + ), + timestamp: Date.now(), + }, + }); + return; + } throw new Error( detail ?? `AI engine request failed: ${response.status}`, ); @@ -553,7 +601,20 @@ export function ChatProvider({ children }: { children: ReactNode }) { onResult: (data) => { receivedResult = true; dispatch({ type: "SET_PROGRESS", progress: null }); - const replyContent = formatWorkflowResponse(data, t); + // The agent's tool calls run server-side, so a usage-limit 402 surfaces here on the + // result (not via the apiClient interceptor that pops the modal for direct calls). + // Fire the matching modal and replace the raw "tool failed: 402…" reason with a + // brief, non-alarming line. + const isLimit = isPaygLimitCode(data.errorCode); + if (isLimit) { + dispatchPaygLimitReached(data.errorSubscribed ?? null); + } + const replyContent = isLimit + ? t( + "chat.responses.usage_limit_reached", + "You've reached your usage limit. Check your plan options to keep going.", + ) + : formatWorkflowResponse(data, t); dispatch({ type: "ADD_MESSAGE", message: { diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 64c177739..27f22b13a 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -27,9 +27,8 @@ import { import type { PolicyRunStatus, PolicyRunView, - PolicyLimitReachedDetail, } from "@app/services/policyPipeline"; -import { POLICY_LIMIT_REACHED_EVENT } from "@app/services/policyPipeline"; +import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; @@ -87,15 +86,7 @@ export function usePolicyAutoRun(): void { if (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return; if (firedLimitModal.current.has(view.runId)) return; firedLimitModal.current.add(view.runId); - try { - window.dispatchEvent( - new CustomEvent(POLICY_LIMIT_REACHED_EVENT, { - detail: { subscribed: view.errorSubscribed ?? null }, - }), - ); - } catch { - // non-browser env (tests / SSR) — no-op. - } + dispatchPaygLimitReached(view.errorSubscribed ?? null); }, []); // Dispatch: for each active policy × each session file not yet run, fire a run. diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index 3049ef28c..e0fcc9bb5 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -56,24 +56,6 @@ export interface BackendPolicy { output: BackendOutputSpec; } -/** - * Window event fired when a policy run is blocked by a usage limit (its tool call got a 402 - * entitlement sentinel). Dispatched from the proprietary auto-run poll; a saas-layer listener - * reads the wallet and opens the matching usage-limit modal. Kept here (proprietary) so both - * layers share one name — proprietary can't import the saas modal API directly. - */ -export const POLICY_LIMIT_REACHED_EVENT = "payg:policyLimitReached"; - -/** Detail carried on {@link POLICY_LIMIT_REACHED_EVENT}. */ -export interface PolicyLimitReachedDetail { - /** - * Whether the blocked team was subscribed (over its spending cap) vs un-subscribed (free - * allowance spent), from the blocking 402. The listener uses it to choose the spend-cap vs - * free-limit modal. Null when unknown → treat as free-limit. - */ - subscribed: boolean | null; -} - /** Lifecycle states of a backend run (mirrors PolicyRunStatus). */ export type PolicyRunStatus = | "PENDING" diff --git a/frontend/editor/src/proprietary/services/usageLimitBridge.ts b/frontend/editor/src/proprietary/services/usageLimitBridge.ts new file mode 100644 index 000000000..5ed6ec28f --- /dev/null +++ b/frontend/editor/src/proprietary/services/usageLimitBridge.ts @@ -0,0 +1,35 @@ +/** + * Bridge for usage-limit (PAYG 402) signals raised by server-side runs — the policy auto-run path + * and the AI agent — neither of which flows through the apiClient interceptor that pops the + * usage-limit modal for direct calls. Their tool calls execute server-side, so the blocking 402 + * never reaches the browser's HTTP client. + * + * Proprietary code can't import the saas modal API (layering: proprietary ↛ saas), so it broadcasts + * this window event instead; the saas-layer UsageLimitModalHost listens and opens the matching + * modal (free → "subscribe", subscribed → "raise cap"). Kept here (proprietary) so both layers + * share one name. + */ +export const PAYG_LIMIT_REACHED_EVENT = "payg:limitReached"; + +/** Detail carried on {@link PAYG_LIMIT_REACHED_EVENT}. */ +export interface PaygLimitReachedDetail { + /** + * Whether the blocked team was subscribed (over its spending cap) vs un-subscribed (free + * allowance spent), from the blocking 402. The listener uses it to choose the spend-cap vs + * free-limit modal. Null when unknown → treat as free-limit. + */ + subscribed: boolean | null; +} + +/** Fire {@link PAYG_LIMIT_REACHED_EVENT}. No-op outside a browser (tests / SSR). */ +export function dispatchPaygLimitReached(subscribed: boolean | null): void { + try { + window.dispatchEvent( + new CustomEvent(PAYG_LIMIT_REACHED_EVENT, { + detail: { subscribed }, + }), + ); + } catch { + // non-browser env (tests / SSR) — no-op. + } +} diff --git a/frontend/editor/src/saas/components/UsageLimitModalHost.tsx b/frontend/editor/src/saas/components/UsageLimitModalHost.tsx index 4c7802cac..1903ec421 100644 --- a/frontend/editor/src/saas/components/UsageLimitModalHost.tsx +++ b/frontend/editor/src/saas/components/UsageLimitModalHost.tsx @@ -6,9 +6,9 @@ import { SPEND_CAP_MODAL_EVENT, } from "@app/components/usageLimitModals"; import { - POLICY_LIMIT_REACHED_EVENT, - type PolicyLimitReachedDetail, -} from "@app/services/policyPipeline"; + PAYG_LIMIT_REACHED_EVENT, + type PaygLimitReachedDetail, +} from "@app/services/usageLimitBridge"; /** * Always-mounted host for the usage-limit warning modals. Mount once (in @@ -17,11 +17,11 @@ import { * only while open, so it reads the wallet (and animates in) on open rather * than on app load. * - *

Also bridges the policy auto-run path: a policy's tool calls run server-side, - * so their usage-limit 402 never reaches the apiClient interceptor that pops these - * modals for direct calls. The proprietary auto-run hook broadcasts {@link - * POLICY_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag) - * instead; we open the matching modal here. + *

Also bridges the server-side run paths (policy auto-run, AI agent): their tool + * calls run server-side, so a usage-limit 402 never reaches the apiClient interceptor + * that pops these modals for direct calls. Those proprietary paths broadcast {@link + * PAYG_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag) instead; + * we open the matching modal here. */ export default function UsageLimitModalHost() { const [freeOpen, setFreeOpen] = useState(false); @@ -30,19 +30,19 @@ export default function UsageLimitModalHost() { useEffect(() => { const onFree = () => setFreeOpen(true); const onSpend = () => setSpendOpen(true); - const onPolicyLimit = (e: Event) => { - const subscribed = (e as CustomEvent).detail + const onServerLimit = (e: Event) => { + const subscribed = (e as CustomEvent).detail ?.subscribed; if (subscribed) setSpendOpen(true); else setFreeOpen(true); }; window.addEventListener(FREE_LIMIT_MODAL_EVENT, onFree); window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend); - window.addEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit); + window.addEventListener(PAYG_LIMIT_REACHED_EVENT, onServerLimit); return () => { window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree); window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend); - window.removeEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit); + window.removeEventListener(PAYG_LIMIT_REACHED_EVENT, onServerLimit); }; }, []);