Files
Stirling-PDF/frontend/editor/src/proprietary/services/usageLimitBridge.ts
T
ConnorYohandGitHub 87723d3ce2 fix(payg): fire the usage-limit modal when an AI agent run hits the limit (#6638)
## 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.
2026-06-12 11:38:07 +01:00

36 lines
1.5 KiB
TypeScript

/**
* 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<PaygLimitReachedDetail>(PAYG_LIMIT_REACHED_EVENT, {
detail: { subscribed },
}),
);
} catch {
// non-browser env (tests / SSR) — no-op.
}
}