mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat: show the assignee's provider usage limit in task properties (#19270)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run on the native Runner through the Codex app-server and the Claude ACP harness > - Both harnesses report the provider account's rate-limit windows: Codex sends `account/rateLimits/updated`, and Claude sends its SDK `rate_limit_event` as `usage_update._meta["_claude/rateLimit"]` > - Paperclip dropped both signals, so an operator could not see that an agent was near or past its provider limit > - This pull request normalizes both signals into one account-scoped snapshot, keeps the latest snapshot per agent, and shows it as one "Usage limit" line in the task properties pane > - The benefit is that an operator sees "5h 78% · resets 15:40" (amber above 80%, "Limited until …" when the provider refuses work, "using credits" when a used-up window falls back to credits) next to the assignee, and can click it for every window ## Linked Issues or Issue Description Refs #19246 Refs #19259 **Is your feature request related to a problem?** Codex and Claude report provider rate-limit windows during a run. Paperclip dropped them: the Codex notification fell through the Rust normalizer with no branch, and ACPX dropped Claude's `_meta`. Operators could not see how close an agent was to its provider limit. **Describe the solution you'd like** Store the latest reported windows per agent and show one compact line in the task properties pane, with a popover that lists every window. **Describe alternatives you've considered** A full gauge in the agent list and the run header. A design review chose one property line with a details popover instead. ## What Changed - Runner (Rust, the runnerd path): new `provider_rate_limits.rs`. Codex `account/rateLimits/updated` becomes a `provider.notice.recorded` event (category `provider.rate_limits`, scope `account`). `CodexProviderState` merges sparse updates and emits a notice only when the windows change. ACPX `usage_update` with a sanitized `rateLimit` field also emits the notice. - Runner (sidecar): `acpx-runtime-sidecar.ts` forwards only `status`, `rateLimitType`, `utilization` and `resetsAt` from `_meta["_claude/rateLimit"]`. This needs the ACPX `_meta` passthrough from #19259. Until that merges, Claude emits nothing. - Runner (TypeScript in-process drivers): the same mapping in `codex-session-notifications.ts` and `codex-acpx-driver.ts` through `drivers/provider-rate-limits.ts`. - Shared: `provider-rate-limits.ts` holds the snapshot type, the parser, the merge, and `summarizeProviderRateLimits` (fullest current window, expiry by reset time, limited versus using credits). - Server: `agent-provider-rate-limits.ts` merges each notice into `agent_runtime_state.state_json.providerRateLimits`, scoped by company and agent. A session reset keeps the snapshot. No migration. - UI: `AgentRateLimitPropertyValue` adds a "Usage limit" row under the assignee in `IssueProperties`. Transcripts treat the notice as run-log only. A run status event refreshes the assignee's runtime state. - Storybook: `Tasks and conversations/Task creation and lifecycle/Usage limit property`. - Docs: `doc/run-log-events.md` describes the notice and its storage. ## Verification - `cd packages/paperclip-runner/runner && cargo test -p paperclip-runner-core --lib rate_limit` and `--test acpx_provider_events`: sparse Codex merge, dedupe, reached window, checkpoint round trip, Claude utilization and rejection, notice shape. - `cd packages/paperclip-runner && npx vitest run src/drivers/provider-rate-limits.test.ts src/drivers/codex` - `cd packages/shared && npx vitest run src/provider-rate-limits.test.ts`: summary, expiry, limited, using credits, parse and merge. - `cd server && npx vitest run src/__tests__/agent-provider-rate-limits.test.ts`: create and merge, out-of-order snapshot, company isolation, unrelated notices, session reset keeps the snapshot. - `cd ui && npx vitest run src/components/transcript src/components/issue-properties src/context/LiveUpdatesProvider` - `pnpm -r typecheck` and `pnpm check:token-gates` pass. - Live test on a local worktree instance: a native Codex agent on a ChatGPT subscription login ran a real task. The runner recorded one `provider.rate_limits` notice. The server stored it. The task properties pane showed "7d 100% · using credits" (this account's weekly window was used up and had credits, and the run succeeded). The transcript showed no extra row. - Not verified live: Claude. The local Claude CLI was not signed in, and the Claude path also needs #19259. The Rust and sidecar paths have tests. - Not run locally: the full `pnpm test:run` and `pnpm build`. CI runs them. ## Risks - Low risk. The snapshot is display data. It does not hold or start work. The existing provider-limit wait (#19246) still reads typed run failures. - Codex can send a rate-limit update often. The runner emits a notice only when the windows change, so run-log growth stays small. - With API-key auth, the harnesses report no windows, so no row appears. This is the intended result. - `CodexProviderState` gains two optional serde fields with defaults, so older checkpoints still load. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (shell, file edits, browser) and extended reasoning. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a66c09f7f
commit
dd889e6a0b
27 files changed
+1911
-5
No files matched your search
@@ -591,6 +591,19 @@ changes, and unrelated thread information do not create a provider notice in
|
||||
chat. Chat also omits the matching notice stored by older runners. Real provider
|
||||
warnings and errors remain visible.
|
||||
|
||||
Provider rate-limit windows use `provider.notice.recorded` with category
|
||||
`provider.rate_limits` and scope `account`. Codex sends them as
|
||||
`account/rateLimits/updated`. Claude sends them as `usage_update` metadata
|
||||
(`_claude/rateLimit`) when it uses a subscription sign-in. The runner merges
|
||||
sparse Codex updates and records a notice only when the windows change. The
|
||||
notice has one `rateLimits` detail: a JSON snapshot with schema
|
||||
`paperclip.provider.rate_limits.v1` (windows with used percent and reset time,
|
||||
credits, and the reached window). Chat does not show the notice. The server
|
||||
keeps the latest snapshot in the agent's runtime state under
|
||||
`providerRateLimits`, and the task properties pane shows it as the assignee's
|
||||
**Usage limit**. The snapshot is display data. It does not hold or start work;
|
||||
the provider-limit wait reads typed run failures.
|
||||
|
||||
Harness status notices use `provider.notice.recorded` with a status category.
|
||||
Codex `error` notifications read `error.message`. A notification with
|
||||
`willRetry: true` becomes a recoverable `provider_retry` notice. A final error
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod provider_backend;
|
||||
pub mod provider_bridge;
|
||||
pub mod provider_events;
|
||||
pub mod provider_failure;
|
||||
pub mod provider_rate_limits;
|
||||
pub mod qualified_launch;
|
||||
pub mod question_response;
|
||||
pub mod replay;
|
||||
|
||||
@@ -625,6 +625,30 @@ fn normalize_provider_notification(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
) -> Result<Vec<NormalizedProviderEvent>, DurableRunnerError> {
|
||||
if state.config.provider == "codex" && method == "account/rateLimits/updated" {
|
||||
// Account-scoped and sparse: merge into the last snapshot and report
|
||||
// only a changed one, so repeated identical updates stay out of the log.
|
||||
let Some(next) = crate::provider_rate_limits::codex_rate_limit_snapshot(
|
||||
params,
|
||||
state.codex_rate_limits.as_ref(),
|
||||
&crate::provider_rate_limits::now_iso(),
|
||||
) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let changed = crate::provider_rate_limits::rate_limit_snapshot_changed(
|
||||
state.codex_rate_limits.as_ref(),
|
||||
&next,
|
||||
);
|
||||
state.codex_rate_limits = Some(next.clone());
|
||||
if !changed {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
state.rate_limit_notice_seq = state.rate_limit_notice_seq.saturating_add(1);
|
||||
return Ok(vec![crate::provider_rate_limits::rate_limit_notice(
|
||||
&next,
|
||||
&format!("codex-rate-limits-{}", state.rate_limit_notice_seq),
|
||||
)]);
|
||||
}
|
||||
if state.config.provider == "codex"
|
||||
&& ["thread/tokenUsage/updated", "paperclip/resumeUsageSnapshot"].contains(&method)
|
||||
{
|
||||
@@ -1185,6 +1209,11 @@ struct CodexProviderState {
|
||||
cursor_cloud_terminal_receipt: Option<Value>,
|
||||
#[serde(default)]
|
||||
codex_usage: Option<CodexRunUsage>,
|
||||
/// Latest Codex account rate-limit snapshot, merged across sparse updates.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codex_rate_limits: Option<Value>,
|
||||
#[serde(default)]
|
||||
rate_limit_notice_seq: u64,
|
||||
#[serde(default)]
|
||||
previous_accounting_origin: Option<CodexAccountingOrigin>,
|
||||
#[serde(default)]
|
||||
@@ -1299,6 +1328,8 @@ impl CodexProviderState {
|
||||
provider_termination_receipt: None,
|
||||
cursor_cloud_terminal_receipt: None,
|
||||
codex_usage,
|
||||
codex_rate_limits: None,
|
||||
rate_limit_notice_seq: 0,
|
||||
previous_accounting_origin: None,
|
||||
completion_contract,
|
||||
tool_bridge,
|
||||
@@ -5919,6 +5950,40 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_rate_limit_updates_emit_only_changed_account_notices() {
|
||||
let mut state = opencode_result_state();
|
||||
state.config.provider = "codex".to_owned();
|
||||
let update = json!({"rateLimits": {"limitId": "codex",
|
||||
"primary": {"usedPercent": 78, "windowDurationMins": 300, "resetsAt": 1_793_893_200_i64}}});
|
||||
let first =
|
||||
normalize_provider_notification(&mut state, "account/rateLimits/updated", &update)
|
||||
.unwrap();
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].event_type, "provider.notice.recorded");
|
||||
assert_eq!(first[0].payload["category"], "provider.rate_limits");
|
||||
assert_eq!(first[0].payload["noticeId"], "codex-rate-limits-1");
|
||||
assert!(
|
||||
normalize_provider_notification(&mut state, "account/rateLimits/updated", &update)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let sparse = json!({"rateLimits": {"limitId": "codex",
|
||||
"secondary": {"usedPercent": 41, "windowDurationMins": 10080, "resetsAt": null}}});
|
||||
let second =
|
||||
normalize_provider_notification(&mut state, "account/rateLimits/updated", &sparse)
|
||||
.unwrap();
|
||||
let snapshot: Value =
|
||||
serde_json::from_str(second[0].payload["details"][0]["value"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(snapshot["windows"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(snapshot["windows"][0]["usedPercent"], 78.0);
|
||||
// Restored checkpoints keep the merged snapshot.
|
||||
let restored: CodexProviderState =
|
||||
serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
|
||||
assert_eq!(restored.codex_rate_limits, state.codex_rate_limits);
|
||||
}
|
||||
|
||||
fn valid_opencode_result() -> Value {
|
||||
json!({
|
||||
"schema": "paperclip.run_result.v1",
|
||||
@@ -6921,6 +6986,8 @@ mod tests {
|
||||
provider_termination_receipt: None,
|
||||
cursor_cloud_terminal_receipt: None,
|
||||
codex_usage: Some(CodexRunUsage::new(false)),
|
||||
codex_rate_limits: None,
|
||||
rate_limit_notice_seq: 0,
|
||||
previous_accounting_origin: None,
|
||||
completion_contract: None,
|
||||
tool_bridge: ProviderToolBridge::default(),
|
||||
|
||||
@@ -1613,7 +1613,7 @@ fn normalize_acpx_status(
|
||||
"requests": 1,
|
||||
"providerCostUsd": if turn_cost { provider_cost } else { 0.0 },
|
||||
});
|
||||
return vec![NormalizedProviderEvent {
|
||||
let usage = NormalizedProviderEvent {
|
||||
event_type: "usage.reported".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
@@ -1628,7 +1628,25 @@ fn normalize_acpx_status(
|
||||
"runDeltaAvailable": run_delta_available,
|
||||
"runDelta": run_delta,
|
||||
}),
|
||||
}];
|
||||
};
|
||||
// Claude forwards its SDK rate_limit_event on usage_update; the
|
||||
// sidecar passes only the sanitized `rateLimit` fields.
|
||||
let rate_limits = payload.get("rateLimit").and_then(|info| {
|
||||
crate::provider_rate_limits::claude_rate_limit_snapshot(
|
||||
info,
|
||||
&crate::provider_rate_limits::now_iso(),
|
||||
)
|
||||
});
|
||||
return match rate_limits {
|
||||
Some(snapshot) => vec![
|
||||
usage,
|
||||
crate::provider_rate_limits::rate_limit_notice(
|
||||
&snapshot,
|
||||
&stable_id(&format!("{item_id}:rate-limits"), "acpx-rate-limits"),
|
||||
),
|
||||
],
|
||||
None => vec![usage],
|
||||
};
|
||||
}
|
||||
if tag == "current_mode_update" {
|
||||
let status = string(payload.get("text"));
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Provider rate-limit windows normalized into one account-scoped snapshot and
|
||||
//! carried as a `provider.notice.recorded` event (category
|
||||
//! `provider.rate_limits`). The control plane keeps the latest snapshot per
|
||||
//! agent; transcripts treat the notice as run-log only. Mirrors
|
||||
//! `src/drivers/provider-rate-limits.ts` and `@paperclipai/shared`
|
||||
//! `provider-rate-limits.ts` (schema `paperclip.provider.rate_limits.v1`).
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::durable::EventPriority;
|
||||
use crate::provider_events::NormalizedProviderEvent;
|
||||
|
||||
pub const PROVIDER_RATE_LIMITS_CATEGORY: &str = "provider.rate_limits";
|
||||
pub const PROVIDER_RATE_LIMITS_SCHEMA: &str = "paperclip.provider.rate_limits.v1";
|
||||
const MAX_WINDOWS: usize = 8;
|
||||
const MAX_DETAIL_CHARS: usize = 4_000;
|
||||
|
||||
fn bounded(value: Option<&Value>, max: usize) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(|text| text.chars().take(max).collect())
|
||||
}
|
||||
|
||||
fn finite(value: Option<&Value>) -> Option<f64> {
|
||||
value
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
fn percent(value: Option<f64>) -> Value {
|
||||
match value {
|
||||
Some(value) => json!((value.clamp(0.0, 1000.0) * 10.0).round() / 10.0),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn epoch_seconds_iso(value: Option<&Value>) -> Value {
|
||||
use aws_smithy_types::{date_time::Format, DateTime};
|
||||
let Some(seconds) = finite(value).filter(|seconds| *seconds > 0.0) else {
|
||||
return Value::Null;
|
||||
};
|
||||
DateTime::from_secs(seconds as i64)
|
||||
.fmt(Format::DateTime)
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn iso_epoch(value: &str) -> Option<i64> {
|
||||
use aws_smithy_types::{date_time::Format, DateTime};
|
||||
DateTime::from_str(value, Format::DateTime)
|
||||
.ok()
|
||||
.map(|date| date.secs())
|
||||
}
|
||||
|
||||
fn iso_epoch_value(value: &Value) -> Option<i64> {
|
||||
value.as_str().and_then(iso_epoch)
|
||||
}
|
||||
|
||||
pub fn now_iso() -> String {
|
||||
use aws_smithy_types::{date_time::Format, DateTime};
|
||||
DateTime::from(std::time::SystemTime::now())
|
||||
.fmt(Format::DateTime)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn window_labels(minutes: Option<i64>) -> (String, String) {
|
||||
match minutes {
|
||||
None => ("Usage window".to_owned(), "Usage".to_owned()),
|
||||
Some(minutes) if minutes % 10_080 == 0 => {
|
||||
let weeks = minutes / 10_080;
|
||||
if weeks == 1 {
|
||||
("Weekly".to_owned(), "7d".to_owned())
|
||||
} else {
|
||||
(format!("{weeks}-week window"), format!("{}d", weeks * 7))
|
||||
}
|
||||
}
|
||||
Some(minutes) if minutes % 1_440 == 0 => {
|
||||
let days = minutes / 1_440;
|
||||
let label = if days == 1 {
|
||||
"Daily".to_owned()
|
||||
} else {
|
||||
format!("{days}-day window")
|
||||
};
|
||||
(label, format!("{days}d"))
|
||||
}
|
||||
Some(minutes) if minutes % 60 == 0 => {
|
||||
let hours = minutes / 60;
|
||||
(format!("{hours}-hour window"), format!("{hours}h"))
|
||||
}
|
||||
Some(minutes) => (format!("{minutes}-minute window"), format!("{minutes}m")),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_window(
|
||||
limit_id: Option<&str>,
|
||||
limit_name: Option<&str>,
|
||||
slot: &str,
|
||||
raw: &Value,
|
||||
) -> Option<Value> {
|
||||
let window = raw.as_object()?;
|
||||
let minutes = finite(window.get("windowDurationMins"))
|
||||
.filter(|minutes| *minutes > 0.0)
|
||||
.map(|minutes| minutes.round() as i64);
|
||||
let (label, short) = window_labels(minutes);
|
||||
let scoped = limit_id.filter(|id| *id != "codex");
|
||||
let scope = scoped.map(|id| limit_name.unwrap_or(id));
|
||||
Some(json!({
|
||||
"id": format!("{}.{slot}", scoped.unwrap_or("codex")),
|
||||
"label": scope.map_or(label.clone(), |scope| format!("{label} ({scope})")),
|
||||
"shortLabel": scope.map_or(short.clone(), |scope| format!("{short} {scope}")),
|
||||
"windowMinutes": minutes,
|
||||
"usedPercent": percent(finite(window.get("usedPercent"))),
|
||||
"resetsAt": epoch_seconds_iso(window.get("resetsAt")),
|
||||
"status": Value::Null,
|
||||
}))
|
||||
}
|
||||
|
||||
fn used(window: &Value) -> f64 {
|
||||
window
|
||||
.get("usedPercent")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(-1.0)
|
||||
}
|
||||
|
||||
/// Merge a sparse update: windows by id; null fields never clear a known value.
|
||||
fn merge(previous: Option<&Value>, mut next: Value) -> Value {
|
||||
let Some(previous) = previous.filter(|previous| previous["provider"] == next["provider"])
|
||||
else {
|
||||
return next;
|
||||
};
|
||||
let mut windows: Vec<Value> = previous["windows"].as_array().cloned().unwrap_or_default();
|
||||
for window in next["windows"].as_array().cloned().unwrap_or_default() {
|
||||
if let Some(prior) = windows.iter_mut().find(|prior| prior["id"] == window["id"]) {
|
||||
let mut merged = window.clone();
|
||||
for key in ["usedPercent", "resetsAt", "windowMinutes", "status"] {
|
||||
if merged[key].is_null() {
|
||||
merged[key] = prior[key].clone();
|
||||
}
|
||||
}
|
||||
*prior = merged;
|
||||
} else {
|
||||
windows.push(window);
|
||||
}
|
||||
}
|
||||
windows.truncate(MAX_WINDOWS);
|
||||
next["windows"] = Value::Array(windows);
|
||||
if next["credits"].is_null() {
|
||||
next["credits"] = previous["credits"].clone();
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Normalize Codex `account/rateLimits/updated`, merged into the previous snapshot.
|
||||
pub fn codex_rate_limit_snapshot(
|
||||
params: &Value,
|
||||
previous: Option<&Value>,
|
||||
observed_at: &str,
|
||||
) -> Option<Value> {
|
||||
let snapshot = params.get("rateLimits")?.as_object()?;
|
||||
let limit_id = bounded(snapshot.get("limitId"), 160);
|
||||
let limit_name = bounded(snapshot.get("limitName"), 160);
|
||||
let windows: Vec<Value> = ["primary", "secondary"]
|
||||
.iter()
|
||||
.filter_map(|slot| {
|
||||
codex_window(
|
||||
limit_id.as_deref(),
|
||||
limit_name.as_deref(),
|
||||
slot,
|
||||
snapshot.get(*slot)?,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let credits = snapshot
|
||||
.get("credits")
|
||||
.and_then(Value::as_object)
|
||||
.map(|credits| {
|
||||
json!({
|
||||
"hasCredits": credits.get("hasCredits").and_then(Value::as_bool),
|
||||
"unlimited": credits.get("unlimited").and_then(Value::as_bool),
|
||||
"balance": bounded(credits.get("balance"), 40),
|
||||
})
|
||||
});
|
||||
let reason = bounded(snapshot.get("rateLimitReachedType"), 160);
|
||||
let mut merged = merge(
|
||||
previous,
|
||||
json!({
|
||||
"schema": PROVIDER_RATE_LIMITS_SCHEMA,
|
||||
"provider": "openai",
|
||||
"windows": windows,
|
||||
"credits": credits,
|
||||
"limitReached": Value::Null,
|
||||
"observedAt": observed_at,
|
||||
}),
|
||||
);
|
||||
let windows = merged["windows"].as_array().cloned().unwrap_or_default();
|
||||
if windows.is_empty() && merged["credits"].is_null() && reason.is_none() {
|
||||
return None;
|
||||
}
|
||||
// A retained window whose reset has passed no longer applies.
|
||||
let at = iso_epoch(observed_at);
|
||||
let current: Vec<&Value> = windows
|
||||
.iter()
|
||||
.filter(|window| match (iso_epoch_value(&window["resetsAt"]), at) {
|
||||
(Some(reset), Some(at)) => reset > at,
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
let fullest = current
|
||||
.iter()
|
||||
.max_by(|a, b| used(a).total_cmp(&used(b)))
|
||||
.map(|window| (*window).clone());
|
||||
let exhausted = current
|
||||
.iter()
|
||||
.find(|window| used(window) >= 100.0)
|
||||
.map(|window| (*window).clone());
|
||||
let reached = if reason.is_some() {
|
||||
fullest
|
||||
} else {
|
||||
exhausted.clone()
|
||||
};
|
||||
if reason.is_some() || exhausted.is_some() {
|
||||
merged["limitReached"] = json!({
|
||||
"windowId": reached.as_ref().map(|window| window["id"].clone()),
|
||||
"reason": reason.unwrap_or_else(|| "rate_limit_reached".to_owned()),
|
||||
"resetsAt": reached.as_ref().map_or(Value::Null, |window| window["resetsAt"].clone()),
|
||||
});
|
||||
}
|
||||
Some(merged)
|
||||
}
|
||||
|
||||
/// Normalize Claude's sanitized `_claude/rateLimit` (SDK rate_limit_info).
|
||||
/// Claude reports one window per event; the control plane merges by window.
|
||||
pub fn claude_rate_limit_snapshot(info: &Value, observed_at: &str) -> Option<Value> {
|
||||
let info = info.as_object()?;
|
||||
let kind = bounded(info.get("rateLimitType"), 64).unwrap_or_else(|| "five_hour".to_owned());
|
||||
let (label, short, minutes): (String, String, Option<i64>) = match kind.as_str() {
|
||||
"five_hour" => ("5-hour window".into(), "5h".into(), Some(300)),
|
||||
"seven_day" => ("Weekly".into(), "7d".into(), Some(10_080)),
|
||||
"seven_day_opus" => ("Weekly (Opus)".into(), "7d Opus".into(), Some(10_080)),
|
||||
"seven_day_sonnet" => ("Weekly (Sonnet)".into(), "7d Sonnet".into(), Some(10_080)),
|
||||
"seven_day_overage_included" => (
|
||||
"Weekly (with extra usage)".into(),
|
||||
"7d".into(),
|
||||
Some(10_080),
|
||||
),
|
||||
"overage" => ("Extra usage".into(), "Extra".into(), None),
|
||||
other => (other.replace('_', " "), other.to_owned(), None),
|
||||
};
|
||||
let status = match info.get("status").and_then(Value::as_str) {
|
||||
Some("allowed") => Some("allowed"),
|
||||
Some("allowed_warning") => Some("warning"),
|
||||
Some("rejected") => Some("rejected"),
|
||||
_ => None,
|
||||
};
|
||||
let utilization = finite(info.get("utilization"));
|
||||
let used_percent = match (status, utilization) {
|
||||
(Some("rejected"), None) => json!(100.0),
|
||||
(_, value) => percent(value.map(|value| value * 100.0)),
|
||||
};
|
||||
let id = format!("claude.{kind}");
|
||||
let resets_at = epoch_seconds_iso(info.get("resetsAt"));
|
||||
let limit_reached = if status == Some("rejected") {
|
||||
json!({"windowId": id, "reason": "rate_limit_reached", "resetsAt": resets_at})
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
Some(json!({
|
||||
"schema": PROVIDER_RATE_LIMITS_SCHEMA,
|
||||
"provider": "anthropic",
|
||||
"windows": [{
|
||||
"id": id, "label": label, "shortLabel": short, "windowMinutes": minutes,
|
||||
"usedPercent": used_percent, "resetsAt": resets_at, "status": status,
|
||||
}],
|
||||
"credits": Value::Null,
|
||||
"limitReached": limit_reached,
|
||||
"observedAt": observed_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// True when the content differs, ignoring the observation time.
|
||||
pub fn rate_limit_snapshot_changed(previous: Option<&Value>, next: &Value) -> bool {
|
||||
let strip = |value: &Value| {
|
||||
let mut value = value.clone();
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.remove("observedAt");
|
||||
}
|
||||
value
|
||||
};
|
||||
previous.is_none_or(|previous| strip(previous) != strip(next))
|
||||
}
|
||||
|
||||
fn summary(snapshot: &Value) -> String {
|
||||
let harness = if snapshot["provider"] == "openai" {
|
||||
"Codex"
|
||||
} else {
|
||||
"Claude"
|
||||
};
|
||||
let windows = snapshot["windows"].as_array().cloned().unwrap_or_default();
|
||||
if let Some(reached) = snapshot["limitReached"].as_object() {
|
||||
let label = windows
|
||||
.iter()
|
||||
.find(|window| Some(&window["id"]) == reached.get("windowId"))
|
||||
.and_then(|window| window["label"].as_str())
|
||||
.map(|label| format!(" ({label})"))
|
||||
.unwrap_or_default();
|
||||
let reset = reached
|
||||
.get("resetsAt")
|
||||
.and_then(Value::as_str)
|
||||
.map(|at| format!("; resets {at}"))
|
||||
.unwrap_or_default();
|
||||
return format!("{harness} rate limit reached{label}{reset}");
|
||||
}
|
||||
let parts: Vec<String> = windows
|
||||
.iter()
|
||||
.filter_map(|window| {
|
||||
Some(format!(
|
||||
"{} {}%",
|
||||
window["label"].as_str()?,
|
||||
window["usedPercent"].as_f64()?
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
format!("{harness} rate limits updated")
|
||||
} else {
|
||||
format!("{harness} rate limits: {}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice event carrying `snapshot` as one JSON detail (kept whole under the cap).
|
||||
pub fn rate_limit_notice(snapshot: &Value, notice_id: &str) -> NormalizedProviderEvent {
|
||||
let mut bounded_snapshot = snapshot.clone();
|
||||
let mut serialized = bounded_snapshot.to_string();
|
||||
while serialized.chars().count() > MAX_DETAIL_CHARS {
|
||||
let Some(windows) = bounded_snapshot["windows"]
|
||||
.as_array_mut()
|
||||
.filter(|w| !w.is_empty())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
windows.pop();
|
||||
serialized = bounded_snapshot.to_string();
|
||||
}
|
||||
let mut payload = Map::new();
|
||||
payload.insert("schema".into(), json!("paperclip.provider.notice.v1"));
|
||||
payload.insert("noticeId".into(), json!(notice_id));
|
||||
payload.insert(
|
||||
"severity".into(),
|
||||
json!(if snapshot["limitReached"].is_null() {
|
||||
"info"
|
||||
} else {
|
||||
"warning"
|
||||
}),
|
||||
);
|
||||
payload.insert("category".into(), json!(PROVIDER_RATE_LIMITS_CATEGORY));
|
||||
payload.insert("scope".into(), json!("account"));
|
||||
payload.insert("recoverable".into(), json!(true));
|
||||
payload.insert("userActionable".into(), json!(false));
|
||||
payload.insert(
|
||||
"summary".into(),
|
||||
json!(summary(snapshot).chars().take(4_000).collect::<String>()),
|
||||
);
|
||||
payload.insert(
|
||||
"details".into(),
|
||||
json!([{"name": "rateLimits", "value": serialized}]),
|
||||
);
|
||||
NormalizedProviderEvent {
|
||||
event_type: "provider.notice.recorded".to_owned(),
|
||||
priority: EventPriority::P1,
|
||||
payload: Value::Object(payload),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const T0: &str = "2026-11-05T15:00:00Z";
|
||||
const RESETS: i64 = 1_793_893_200; // 2026-11-05T15:40:00Z
|
||||
|
||||
#[test]
|
||||
fn codex_snapshot_merges_sparse_updates_and_marks_reached() {
|
||||
let first = codex_rate_limit_snapshot(
|
||||
&json!({"rateLimits": {"limitId": "codex",
|
||||
"primary": {"usedPercent": 78, "windowDurationMins": 300, "resetsAt": RESETS},
|
||||
"secondary": {"usedPercent": 41, "windowDurationMins": 10080, "resetsAt": RESETS + 86_400},
|
||||
"credits": {"hasCredits": true, "unlimited": false, "balance": "12.40"}}}),
|
||||
None,
|
||||
T0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first["windows"][0]["label"], "5-hour window");
|
||||
assert_eq!(first["windows"][0]["resetsAt"], "2026-11-05T15:40:00Z");
|
||||
assert_eq!(first["windows"][1]["shortLabel"], "7d");
|
||||
assert!(first["limitReached"].is_null());
|
||||
|
||||
let second = codex_rate_limit_snapshot(
|
||||
&json!({"rateLimits": {"limitId": "codex",
|
||||
"primary": {"usedPercent": 100, "windowDurationMins": null, "resetsAt": null},
|
||||
"rateLimitReachedType": "rate_limit_reached"}}),
|
||||
Some(&first),
|
||||
T0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(second["windows"][0]["usedPercent"], 100.0);
|
||||
assert_eq!(second["windows"][0]["windowMinutes"], 300);
|
||||
assert_eq!(second["windows"][1]["usedPercent"], 41.0);
|
||||
assert_eq!(second["credits"]["balance"], "12.40");
|
||||
assert_eq!(second["limitReached"]["windowId"], "codex.primary");
|
||||
assert_eq!(second["limitReached"]["resetsAt"], "2026-11-05T15:40:00Z");
|
||||
assert!(rate_limit_snapshot_changed(Some(&first), &second));
|
||||
let mut same = second.clone();
|
||||
same["observedAt"] = json!("2026-11-05T16:00:00Z");
|
||||
assert!(!rate_limit_snapshot_changed(Some(&second), &same));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_reached_window_skips_retained_expired_windows() {
|
||||
let first = codex_rate_limit_snapshot(
|
||||
&json!({"rateLimits": {"limitId": "codex",
|
||||
"primary": {"usedPercent": 100, "windowDurationMins": 300, "resetsAt": RESETS}}}),
|
||||
None,
|
||||
T0,
|
||||
)
|
||||
.unwrap();
|
||||
let later = codex_rate_limit_snapshot(
|
||||
&json!({"rateLimits": {"limitId": "codex",
|
||||
"secondary": {"usedPercent": 100, "windowDurationMins": 10080, "resetsAt": RESETS + 86_400},
|
||||
"rateLimitReachedType": "rate_limit_reached"}}),
|
||||
Some(&first),
|
||||
"2026-11-05T16:00:00Z",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(later["limitReached"]["windowId"], "codex.secondary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_empty_update_is_ignored() {
|
||||
assert!(
|
||||
codex_rate_limit_snapshot(&json!({"rateLimits": {"primary": null}}), None, T0)
|
||||
.is_none()
|
||||
);
|
||||
assert!(codex_rate_limit_snapshot(&json!({}), None, T0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_snapshot_maps_utilization_and_rejection() {
|
||||
let warn = claude_rate_limit_snapshot(
|
||||
&json!({"status": "allowed_warning", "rateLimitType": "five_hour", "utilization": 0.86, "resetsAt": RESETS}),
|
||||
T0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(warn["windows"][0]["usedPercent"], 86.0);
|
||||
assert_eq!(warn["windows"][0]["status"], "warning");
|
||||
assert!(warn["limitReached"].is_null());
|
||||
let rejected = claude_rate_limit_snapshot(
|
||||
&json!({"status": "rejected", "rateLimitType": "seven_day"}),
|
||||
T0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rejected["windows"][0]["usedPercent"], 100.0);
|
||||
assert_eq!(rejected["limitReached"]["windowId"], "claude.seven_day");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_carries_snapshot_as_json_detail() {
|
||||
let snapshot =
|
||||
claude_rate_limit_snapshot(&json!({"status": "allowed", "utilization": 0.5}), T0)
|
||||
.unwrap();
|
||||
let event = rate_limit_notice(&snapshot, "turn-1:rate-limits");
|
||||
assert_eq!(event.event_type, "provider.notice.recorded");
|
||||
assert_eq!(event.payload["category"], PROVIDER_RATE_LIMITS_CATEGORY);
|
||||
assert_eq!(event.payload["scope"], "account");
|
||||
assert_eq!(
|
||||
event.payload["summary"],
|
||||
"Claude rate limits: 5-hour window 50%"
|
||||
);
|
||||
let detail: Value =
|
||||
serde_json::from_str(event.payload["details"][0]["value"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(detail, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -737,6 +737,63 @@ fn leaves_operational_semantic_and_terminal_events_to_the_adapter() {
|
||||
assert!(normalize(AcpxRuntimeEventKind::Done, json!({"type":"done"})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_rate_limit_on_usage_update_becomes_an_account_notice() {
|
||||
let mut scope = AcpxEventScope::new("run-1").unwrap();
|
||||
scope.bind_turn("turn-1").unwrap();
|
||||
let decoded = decode_acpx_event(
|
||||
&scope,
|
||||
&AcpxSidecarEvent {
|
||||
sequence: 1,
|
||||
event_type: GeneratedAcpxSidecarEventType::RuntimeEvent,
|
||||
run_id: Some("run-1".to_owned()),
|
||||
turn_id: Some("turn-1".to_owned()),
|
||||
payload: json!({
|
||||
"type":"status", "tag":"usage_update",
|
||||
"rateLimit":{
|
||||
"status":"allowed_warning", "rateLimitType":"five_hour",
|
||||
"utilization":0.86, "resetsAt":1_793_893_200
|
||||
}
|
||||
}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let AcpxEventPayload::Runtime {
|
||||
kind,
|
||||
tool_operation,
|
||||
payload,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
panic!("runtime usage must decode as a runtime payload");
|
||||
};
|
||||
let events =
|
||||
normalize_acpx_runtime_event(kind, &payload, tool_operation, "event-7", "turn-1", 3);
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0].event_type, "usage.reported");
|
||||
let notice = &events[1];
|
||||
assert_eq!(notice.event_type, "provider.notice.recorded");
|
||||
assert_eq!(notice.payload["category"], "provider.rate_limits");
|
||||
assert_eq!(notice.payload["scope"], "account");
|
||||
assert_eq!(notice.payload["noticeId"], "event-7:rate-limits");
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_str(notice.payload["details"][0]["value"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(snapshot["provider"], "anthropic");
|
||||
assert_eq!(snapshot["windows"][0]["id"], "claude.five_hour");
|
||||
assert_eq!(snapshot["windows"][0]["usedPercent"], 86.0);
|
||||
assert_eq!(snapshot["windows"][0]["resetsAt"], "2026-11-05T15:40:00Z");
|
||||
|
||||
let plain = normalize(
|
||||
AcpxRuntimeEventKind::Status,
|
||||
json!({"type":"status", "tag":"usage_update"}),
|
||||
);
|
||||
assert_eq!(
|
||||
plain.len(),
|
||||
1,
|
||||
"usage without rate-limit data stays a single event"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_status_notices_pair_by_key_and_keep_details() {
|
||||
let notice = |phase: &str| {
|
||||
|
||||
@@ -1166,6 +1166,7 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent<AcpRuntimeEvent> &
|
||||
used: safeNonNegativeNumber(event.used),
|
||||
size: safeNonNegativeNumber(event.size),
|
||||
...safeUsage(event.cost, event.breakdown),
|
||||
...safeRateLimit(event),
|
||||
});
|
||||
}
|
||||
if (event.type === "tool_call") {
|
||||
@@ -1300,6 +1301,28 @@ function sanitizeRuntimeStatus(value: unknown): Record<string, unknown> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude forwards its SDK `rate_limit_event` as
|
||||
* `usage_update._meta["_claude/rateLimit"]`. Pass only the window fields the
|
||||
* rate-limit normalizer reads; billing and overage details stay behind.
|
||||
*/
|
||||
function safeRateLimit(event: unknown): Record<string, unknown> {
|
||||
const raw = record(event);
|
||||
if (raw.tag !== "usage_update") return {};
|
||||
const info = record(record(raw._meta)["_claude/rateLimit"]);
|
||||
if (Object.keys(info).length === 0) return {};
|
||||
const status = ["allowed", "allowed_warning", "rejected"].includes(String(info.status)) ? info.status : null;
|
||||
const rateLimitType = typeof info.rateLimitType === "string" && /^[a-z0-9_]{1,64}$/.test(info.rateLimitType)
|
||||
? info.rateLimitType
|
||||
: null;
|
||||
const utilization = typeof info.utilization === "number" && Number.isFinite(info.utilization) && info.utilization >= 0
|
||||
? Math.min(info.utilization, 10)
|
||||
: null;
|
||||
const resetsAt = safeNonNegativeNumber(info.resetsAt);
|
||||
if (status === null && utilization === null) return {};
|
||||
return { rateLimit: { status, rateLimitType, utilization, resetsAt } };
|
||||
}
|
||||
|
||||
function safeUsage(cost: unknown, breakdown: unknown): Record<string, unknown> {
|
||||
const nativeCost = record(cost);
|
||||
const nativeBreakdown = record(
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
|
||||
import { workspaceRootsFor } from "../../tool-file-changes.js";
|
||||
import { AcpxTurnControlLedger, parseAcpxTurnControl, type AcpxTurnControlMode } from "./turn-controls.js";
|
||||
import { claudeRateLimitSnapshot, rateLimitNoticeEvent, rateLimitSnapshotChanged, type ProviderRateLimitSnapshot } from "../provider-rate-limits.js";
|
||||
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "./usage-accounting.js";
|
||||
|
||||
const MAX_BUFFERED_EVENTS = 512;
|
||||
@@ -783,6 +784,8 @@ class CodexAcpxSession implements HarnessSession {
|
||||
turnId: string;
|
||||
} | null = null;
|
||||
#usage: Record<string, unknown> | null = null;
|
||||
#rateLimits: ProviderRateLimitSnapshot | null = null;
|
||||
#rateLimitNoticeSequence = 0;
|
||||
#assistantText = "";
|
||||
/** Claude compaction banners in this session; pairs start and end notices. */
|
||||
#compactionCount = 0;
|
||||
@@ -1774,6 +1777,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
cumulative: event.breakdown,
|
||||
cost: event.cost,
|
||||
});
|
||||
this.#observeRateLimits(event as { _meta?: unknown }, turnId);
|
||||
}
|
||||
this.#workspaceRoots ??= workspaceRootsFor(this.#input.workingDirectory);
|
||||
for (const canonical of canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
@@ -1789,6 +1793,18 @@ class CodexAcpxSession implements HarnessSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Claude reports `rate_limit_event` as `usage_update._meta["_claude/rateLimit"]`. */
|
||||
#observeRateLimits(event: { _meta?: unknown }, turnId: string): void {
|
||||
const next = claudeRateLimitSnapshot(event._meta, this.#rateLimits, this.#now().toISOString());
|
||||
if (!next) return;
|
||||
if (rateLimitSnapshotChanged(this.#rateLimits, next)) {
|
||||
const itemId = `${turnId}:rate-limits:${++this.#rateLimitNoticeSequence}`;
|
||||
const notice = rateLimitNoticeEvent(next, itemId);
|
||||
this.#emit(notice.eventType, notice.payload, { turnId, itemId });
|
||||
}
|
||||
this.#rateLimits = next;
|
||||
}
|
||||
|
||||
async #handleExtensionInput(
|
||||
turnId: string,
|
||||
input: AcpxExtensionInput,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observeCodexUsage, codexRunUsage } from "./codex-usage-baseline.js";
|
||||
import { codexRateLimitSnapshot, rateLimitNoticeEvent, rateLimitSnapshotChanged } from "../provider-rate-limits.js";
|
||||
import { classifyCodexNotification } from "./codex-notification-identity.js";
|
||||
import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
|
||||
import { canonicalProviderEventsFromCodex, isCanonicalProviderEventType } from "../../provider-events.js";
|
||||
@@ -153,6 +154,20 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
|
||||
if (params.runId === state.runId) state.emit("runtime.activity.recorded", record(params.payload));
|
||||
return;
|
||||
}
|
||||
if (notification.method === "account/rateLimits/updated") {
|
||||
// Account-scoped: it names no thread or turn, so it bypasses thread binding.
|
||||
const next = codexRateLimitSnapshot(record(notification.params), state.rateLimits, state.now().toISOString());
|
||||
if (next && rateLimitSnapshotChanged(state.rateLimits, next)) {
|
||||
const itemId = `codex-rate-limits:${++state.rateLimitNoticeSequence}`;
|
||||
const notice = rateLimitNoticeEvent(next, itemId);
|
||||
state.emit(notice.eventType, notice.payload, {
|
||||
...(state.activeTurnId ? { turnId: state.activeTurnId } : {}),
|
||||
itemId,
|
||||
});
|
||||
}
|
||||
if (next) state.rateLimits = next;
|
||||
return;
|
||||
}
|
||||
if (!isSupportedCodexNotificationMethod(notification.method)) return;
|
||||
if (notification.method === "item/completed" && notification.params.kind === "steering_acknowledgement"
|
||||
&& !notification.params.threadId && !notification.params.turnId && !notification.params.thread && !notification.params.turn) return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type CodexUsageBaseline, codexRunUsage } from "./codex-usage-baseline.js";
|
||||
import type { ProviderRateLimitSnapshot } from "../provider-rate-limits.js";
|
||||
import { workspaceRootsFor } from "../../tool-file-changes.js";
|
||||
import type {
|
||||
HarnessRuntimeRequest,
|
||||
@@ -118,6 +119,9 @@ export class CodexSessionState {
|
||||
activeTurnId: string | null;
|
||||
usageSnapshot: Record<string, unknown> | null = null;
|
||||
codexUsageBaseline: CodexUsageBaseline | null = null;
|
||||
/** Latest account rate-limit snapshot, merged across sparse updates. */
|
||||
rateLimits: ProviderRateLimitSnapshot | null = null;
|
||||
rateLimitNoticeSequence = 0;
|
||||
result: PrpStructuredRunResult | null = null;
|
||||
resultFingerprint: string | null = null;
|
||||
readonly normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"];
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PROVIDER_RATE_LIMITS_CATEGORY,
|
||||
claudeRateLimitSnapshot,
|
||||
codexRateLimitSnapshot,
|
||||
rateLimitNoticeEvent,
|
||||
rateLimitSnapshotChanged,
|
||||
} from "./provider-rate-limits.js";
|
||||
|
||||
const T0 = "2026-11-05T15:00:00.000Z";
|
||||
const resetsAt = Date.parse("2026-11-05T15:40:00.000Z") / 1000;
|
||||
|
||||
describe("codexRateLimitSnapshot", () => {
|
||||
it("normalizes primary and secondary windows, credits and reset times", () => {
|
||||
const snapshot = codexRateLimitSnapshot({
|
||||
rateLimits: {
|
||||
limitId: "codex", limitName: null, normalModelSlug: null,
|
||||
primary: { usedPercent: 78, windowDurationMins: 300, resetsAt },
|
||||
secondary: { usedPercent: 41, windowDurationMins: 10080, resetsAt: resetsAt + 86400 },
|
||||
credits: { hasCredits: true, unlimited: false, balance: "12.40" },
|
||||
individualLimit: null, spendControlReached: null, planType: "pro", rateLimitReachedType: null,
|
||||
},
|
||||
}, null, T0);
|
||||
expect(snapshot).toMatchObject({
|
||||
provider: "openai",
|
||||
credits: { hasCredits: true, unlimited: false, balance: "12.40" },
|
||||
limitReached: null,
|
||||
windows: [
|
||||
{ id: "codex.primary", label: "5-hour window", shortLabel: "5h", usedPercent: 78, resetsAt: "2026-11-05T15:40:00.000Z" },
|
||||
{ id: "codex.secondary", label: "Weekly", shortLabel: "7d", usedPercent: 41 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges a sparse update without clearing known windows", () => {
|
||||
const first = codexRateLimitSnapshot({ rateLimits: {
|
||||
limitId: "codex", primary: { usedPercent: 40, windowDurationMins: 300, resetsAt },
|
||||
secondary: { usedPercent: 10, windowDurationMins: 10080, resetsAt: resetsAt + 86400 }, credits: null,
|
||||
} }, null, T0);
|
||||
const second = codexRateLimitSnapshot({ rateLimits: {
|
||||
limitId: "codex", primary: { usedPercent: 55, windowDurationMins: null, resetsAt: null }, secondary: null, credits: null,
|
||||
} }, first, T0);
|
||||
expect(second?.windows.map((window) => [window.id, window.usedPercent, window.windowMinutes, window.resetsAt])).toEqual([
|
||||
["codex.primary", 55, 300, "2026-11-05T15:40:00.000Z"],
|
||||
["codex.secondary", 10, 10080, "2026-11-06T15:40:00.000Z"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the fullest window reached when Codex reports a reached type", () => {
|
||||
const snapshot = codexRateLimitSnapshot({ rateLimits: {
|
||||
limitId: "codex", primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
|
||||
secondary: { usedPercent: 70, windowDurationMins: 10080, resetsAt: resetsAt + 86400 },
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
} }, null, T0);
|
||||
expect(snapshot?.limitReached).toEqual({
|
||||
windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a retained window whose reset has passed when picking the reached one", () => {
|
||||
const first = codexRateLimitSnapshot({ rateLimits: {
|
||||
limitId: "codex", primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
|
||||
} }, null, T0);
|
||||
const later = codexRateLimitSnapshot({ rateLimits: {
|
||||
limitId: "codex", secondary: { usedPercent: 100, windowDurationMins: 10080, resetsAt: resetsAt + 86400 },
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
} }, first, "2026-11-05T16:00:00.000Z");
|
||||
expect(later?.limitReached?.windowId).toBe("codex.secondary");
|
||||
});
|
||||
|
||||
it("returns null for an empty update", () => {
|
||||
expect(codexRateLimitSnapshot({ rateLimits: { limitId: "codex", primary: null, secondary: null } }, null, T0)).toBeNull();
|
||||
expect(codexRateLimitSnapshot({}, null, T0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeRateLimitSnapshot", () => {
|
||||
it("maps utilization (0..1) per rate-limit type and merges types", () => {
|
||||
const five = claudeRateLimitSnapshot({ "_claude/rateLimit": {
|
||||
status: "allowed_warning", rateLimitType: "five_hour", utilization: 0.86, resetsAt,
|
||||
} }, null, T0);
|
||||
const week = claudeRateLimitSnapshot({ "_claude/rateLimit": {
|
||||
status: "allowed", rateLimitType: "seven_day", utilization: 0.63,
|
||||
} }, five, T0);
|
||||
expect(week?.windows).toEqual([
|
||||
expect.objectContaining({ id: "claude.five_hour", label: "5-hour window", usedPercent: 86, status: "warning" }),
|
||||
expect.objectContaining({ id: "claude.seven_day", label: "Weekly", usedPercent: 63, status: "allowed" }),
|
||||
]);
|
||||
expect(week?.limitReached).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a rejected window as reached even without utilization", () => {
|
||||
const snapshot = claudeRateLimitSnapshot({ "_claude/rateLimit": {
|
||||
status: "rejected", rateLimitType: "five_hour", resetsAt,
|
||||
} }, null, T0);
|
||||
expect(snapshot?.windows[0]?.usedPercent).toBe(100);
|
||||
expect(snapshot?.limitReached).toEqual({
|
||||
windowId: "claude.five_hour", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores usage updates without rate-limit metadata", () => {
|
||||
expect(claudeRateLimitSnapshot(undefined, null, T0)).toBeNull();
|
||||
expect(claudeRateLimitSnapshot({ usage: {} }, null, T0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitNoticeEvent", () => {
|
||||
it("emits an account-scoped notice carrying the snapshot as one JSON detail", () => {
|
||||
const snapshot = codexRateLimitSnapshot({ rateLimits: {
|
||||
limitId: "codex", primary: { usedPercent: 78, windowDurationMins: 300, resetsAt },
|
||||
} }, null, T0)!;
|
||||
const event = rateLimitNoticeEvent(snapshot, "codex-rate-limits:1");
|
||||
expect(event.eventType).toBe("provider.notice.recorded");
|
||||
expect(event.payload).toMatchObject({
|
||||
schema: "paperclip.provider.notice.v1", category: PROVIDER_RATE_LIMITS_CATEGORY,
|
||||
scope: "account", severity: "info", summary: "Codex rate limits: 5-hour window 78%",
|
||||
});
|
||||
const details = event.payload.details as Array<{ name: string; value: string }>;
|
||||
expect(JSON.parse(details[0]!.value)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("only reports a change when the content differs", () => {
|
||||
const a = codexRateLimitSnapshot({ rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt } } }, null, T0)!;
|
||||
const b = codexRateLimitSnapshot({ rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt } } }, a, "2026-11-05T15:05:00.000Z")!;
|
||||
expect(rateLimitSnapshotChanged(a, b)).toBe(false);
|
||||
const c = codexRateLimitSnapshot({ rateLimits: { primary: { usedPercent: 6 } } }, b, T0)!;
|
||||
expect(rateLimitSnapshotChanged(b, c)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { CanonicalProviderEvent } from "../provider-events.js";
|
||||
|
||||
/**
|
||||
* Provider rate-limit windows reported by a harness, normalized into one
|
||||
* account-scoped snapshot and carried on a `provider.notice.recorded` event
|
||||
* (category {@link PROVIDER_RATE_LIMITS_CATEGORY}). The server keeps the latest
|
||||
* snapshot per agent; the transcript treats it as run-log only.
|
||||
*
|
||||
* Sources:
|
||||
* - Codex app-server `account/rateLimits/updated` (sparse RateLimitSnapshot:
|
||||
* primary/secondary windows, credits, rateLimitReachedType).
|
||||
* - Claude `usage_update._meta["_claude/rateLimit"]` (SDK rate_limit_event:
|
||||
* one window per event, utilization 0..1, status).
|
||||
*/
|
||||
export const PROVIDER_RATE_LIMITS_CATEGORY = "provider.rate_limits";
|
||||
export const PROVIDER_RATE_LIMITS_SCHEMA = "paperclip.provider.rate_limits.v1";
|
||||
export const PROVIDER_RATE_LIMITS_DETAIL = "rateLimits";
|
||||
|
||||
export interface ProviderRateLimitWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
windowMinutes: number | null;
|
||||
usedPercent: number | null;
|
||||
resetsAt: string | null;
|
||||
status: "allowed" | "warning" | "rejected" | null;
|
||||
}
|
||||
|
||||
export interface ProviderRateLimitSnapshot {
|
||||
schema: typeof PROVIDER_RATE_LIMITS_SCHEMA;
|
||||
provider: "openai" | "anthropic";
|
||||
windows: ProviderRateLimitWindow[];
|
||||
credits: { hasCredits: boolean | null; unlimited: boolean | null; balance: string | null } | null;
|
||||
limitReached: { windowId: string | null; reason: string; resetsAt: string | null } | null;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const obj = (value: unknown): Obj | null =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : null;
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
const str = (value: unknown, max = 160): string | null =>
|
||||
typeof value === "string" && value.length > 0 ? value.slice(0, max) : null;
|
||||
const bool = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null);
|
||||
|
||||
function epochSecondsIso(value: unknown): string | null {
|
||||
const seconds = num(value);
|
||||
if (seconds === null || seconds <= 0) return null;
|
||||
const date = new Date(seconds * 1000);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
function percent(value: number | null): number | null {
|
||||
if (value === null) return null;
|
||||
return Math.round(Math.min(1000, Math.max(0, value)) * 10) / 10;
|
||||
}
|
||||
|
||||
export function windowLabels(minutes: number | null): { label: string; shortLabel: string } {
|
||||
if (minutes === null) return { label: "Usage window", shortLabel: "Usage" };
|
||||
if (minutes % 10080 === 0) {
|
||||
const weeks = minutes / 10080;
|
||||
return weeks === 1 ? { label: "Weekly", shortLabel: "7d" } : { label: `${weeks}-week window`, shortLabel: `${weeks * 7}d` };
|
||||
}
|
||||
if (minutes % 1440 === 0) {
|
||||
const days = minutes / 1440;
|
||||
return { label: days === 1 ? "Daily" : `${days}-day window`, shortLabel: `${days}d` };
|
||||
}
|
||||
if (minutes % 60 === 0) {
|
||||
const hours = minutes / 60;
|
||||
return { label: `${hours}-hour window`, shortLabel: `${hours}h` };
|
||||
}
|
||||
return { label: `${minutes}-minute window`, shortLabel: `${minutes}m` };
|
||||
}
|
||||
|
||||
/** Merge a sparse update: windows by id, null fields never clear a known value. */
|
||||
export function mergeRateLimitSnapshot(
|
||||
previous: ProviderRateLimitSnapshot | null,
|
||||
next: ProviderRateLimitSnapshot,
|
||||
): ProviderRateLimitSnapshot {
|
||||
if (!previous || previous.provider !== next.provider) return next;
|
||||
const windows = new Map(previous.windows.map((window) => [window.id, window]));
|
||||
for (const window of next.windows) {
|
||||
const prior = windows.get(window.id);
|
||||
windows.set(window.id, prior ? {
|
||||
...window,
|
||||
usedPercent: window.usedPercent ?? prior.usedPercent,
|
||||
resetsAt: window.resetsAt ?? prior.resetsAt,
|
||||
windowMinutes: window.windowMinutes ?? prior.windowMinutes,
|
||||
status: window.status ?? prior.status,
|
||||
} : window);
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
windows: [...windows.values()].slice(0, 8),
|
||||
credits: next.credits ?? previous.credits,
|
||||
};
|
||||
}
|
||||
|
||||
function codexWindow(limitId: string | null, limitName: string | null, slot: "primary" | "secondary", raw: unknown): ProviderRateLimitWindow | null {
|
||||
const window = obj(raw);
|
||||
if (!window) return null;
|
||||
const minutes = num(window.windowDurationMins);
|
||||
const labels = windowLabels(minutes !== null && minutes > 0 ? Math.round(minutes) : null);
|
||||
const scoped = limitId && limitId !== "codex";
|
||||
const scope = scoped ? (limitName ?? limitId) : null;
|
||||
return {
|
||||
id: `${scoped ? limitId : "codex"}.${slot}`,
|
||||
label: scope ? `${labels.label} (${scope})` : labels.label,
|
||||
shortLabel: scope ? `${labels.shortLabel} ${scope}` : labels.shortLabel,
|
||||
windowMinutes: minutes !== null && minutes > 0 ? Math.round(minutes) : null,
|
||||
usedPercent: percent(num(window.usedPercent)),
|
||||
resetsAt: epochSecondsIso(window.resetsAt),
|
||||
status: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize Codex `account/rateLimits/updated` params, merged into the previous snapshot. */
|
||||
export function codexRateLimitSnapshot(
|
||||
params: Obj,
|
||||
previous: ProviderRateLimitSnapshot | null,
|
||||
observedAt: string,
|
||||
): ProviderRateLimitSnapshot | null {
|
||||
const snapshot = obj(params.rateLimits);
|
||||
if (!snapshot) return null;
|
||||
const limitId = str(snapshot.limitId);
|
||||
const limitName = str(snapshot.limitName);
|
||||
const windows = (["primary", "secondary"] as const)
|
||||
.map((slot) => codexWindow(limitId, limitName, slot, snapshot[slot]))
|
||||
.filter((window): window is ProviderRateLimitWindow => window !== null);
|
||||
const creditsRaw = obj(snapshot.credits);
|
||||
const credits = creditsRaw
|
||||
? { hasCredits: bool(creditsRaw.hasCredits), unlimited: bool(creditsRaw.unlimited), balance: str(creditsRaw.balance, 40) }
|
||||
: null;
|
||||
const reason = str(snapshot.rateLimitReachedType);
|
||||
const merged = mergeRateLimitSnapshot(previous, {
|
||||
schema: PROVIDER_RATE_LIMITS_SCHEMA,
|
||||
provider: "openai",
|
||||
windows,
|
||||
credits,
|
||||
limitReached: null,
|
||||
observedAt,
|
||||
});
|
||||
if (merged.windows.length === 0 && !merged.credits && !reason) return null;
|
||||
// The reached type names no window; attribute it to the fullest current one.
|
||||
// A retained window whose reset has passed no longer applies.
|
||||
const at = Date.parse(observedAt);
|
||||
const current = merged.windows.filter((window) => !window.resetsAt || Date.parse(window.resetsAt) > at);
|
||||
const fullest = [...current].sort((a, b) => (b.usedPercent ?? -1) - (a.usedPercent ?? -1))[0] ?? null;
|
||||
const exhausted = current.find((window) => (window.usedPercent ?? 0) >= 100) ?? null;
|
||||
const reachedWindow = reason ? fullest : exhausted;
|
||||
merged.limitReached = reason || exhausted
|
||||
? { windowId: reachedWindow?.id ?? null, reason: reason ?? "rate_limit_reached", resetsAt: reachedWindow?.resetsAt ?? null }
|
||||
: null;
|
||||
return merged;
|
||||
}
|
||||
|
||||
const CLAUDE_WINDOWS: Record<string, { label: string; shortLabel: string; minutes: number | null }> = {
|
||||
five_hour: { label: "5-hour window", shortLabel: "5h", minutes: 300 },
|
||||
seven_day: { label: "Weekly", shortLabel: "7d", minutes: 10080 },
|
||||
seven_day_opus: { label: "Weekly (Opus)", shortLabel: "7d Opus", minutes: 10080 },
|
||||
seven_day_sonnet: { label: "Weekly (Sonnet)", shortLabel: "7d Sonnet", minutes: 10080 },
|
||||
seven_day_overage_included: { label: "Weekly (with extra usage)", shortLabel: "7d", minutes: 10080 },
|
||||
overage: { label: "Extra usage", shortLabel: "Extra", minutes: null },
|
||||
};
|
||||
|
||||
/** Normalize Claude's `_claude/rateLimit` (SDK rate_limit_info), merged into the previous snapshot. */
|
||||
export function claudeRateLimitSnapshot(
|
||||
meta: unknown,
|
||||
previous: ProviderRateLimitSnapshot | null,
|
||||
observedAt: string,
|
||||
): ProviderRateLimitSnapshot | null {
|
||||
const info = obj(obj(meta)?.["_claude/rateLimit"]);
|
||||
if (!info) return null;
|
||||
const type = str(info.rateLimitType) ?? "five_hour";
|
||||
const known = CLAUDE_WINDOWS[type] ?? { label: type.replaceAll("_", " "), shortLabel: type, minutes: null };
|
||||
const rawStatus = str(info.status);
|
||||
const status = rawStatus === "allowed" ? "allowed" : rawStatus === "allowed_warning" ? "warning" : rawStatus === "rejected" ? "rejected" : null;
|
||||
const utilization = num(info.utilization);
|
||||
const window: ProviderRateLimitWindow = {
|
||||
id: `claude.${type}`,
|
||||
label: known.label,
|
||||
shortLabel: known.shortLabel,
|
||||
windowMinutes: known.minutes,
|
||||
usedPercent: status === "rejected" && utilization === null ? 100 : percent(utilization === null ? null : utilization * 100),
|
||||
resetsAt: epochSecondsIso(info.resetsAt),
|
||||
status,
|
||||
};
|
||||
const merged = mergeRateLimitSnapshot(previous, {
|
||||
schema: PROVIDER_RATE_LIMITS_SCHEMA,
|
||||
provider: "anthropic",
|
||||
windows: [window],
|
||||
credits: null,
|
||||
limitReached: null,
|
||||
observedAt,
|
||||
});
|
||||
const rejected = merged.windows.find((candidate) => candidate.status === "rejected") ?? null;
|
||||
merged.limitReached = rejected
|
||||
? { windowId: rejected.id, reason: "rate_limit_reached", resetsAt: rejected.resetsAt }
|
||||
: null;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function fingerprint(snapshot: ProviderRateLimitSnapshot): string {
|
||||
const { observedAt: _observedAt, ...rest } = snapshot;
|
||||
return JSON.stringify(rest);
|
||||
}
|
||||
|
||||
export function rateLimitSnapshotChanged(
|
||||
previous: ProviderRateLimitSnapshot | null,
|
||||
next: ProviderRateLimitSnapshot,
|
||||
): boolean {
|
||||
return !previous || fingerprint(previous) !== fingerprint(next);
|
||||
}
|
||||
|
||||
function summary(snapshot: ProviderRateLimitSnapshot): string {
|
||||
const harness = snapshot.provider === "openai" ? "Codex" : "Claude";
|
||||
if (snapshot.limitReached) {
|
||||
const window = snapshot.windows.find((candidate) => candidate.id === snapshot.limitReached?.windowId);
|
||||
return `${harness} rate limit reached${window ? ` (${window.label})` : ""}${snapshot.limitReached.resetsAt ? `; resets ${snapshot.limitReached.resetsAt}` : ""}`;
|
||||
}
|
||||
const parts = snapshot.windows
|
||||
.filter((window) => window.usedPercent !== null)
|
||||
.map((window) => `${window.label} ${window.usedPercent}%`);
|
||||
return parts.length ? `${harness} rate limits: ${parts.join(", ")}` : `${harness} rate limits updated`;
|
||||
}
|
||||
|
||||
export function rateLimitNoticeEvent(snapshot: ProviderRateLimitSnapshot, itemId: string): CanonicalProviderEvent {
|
||||
// Notice detail values are capped at 4000 characters; keep the JSON whole.
|
||||
const bounded = { ...snapshot, windows: [...snapshot.windows] };
|
||||
let serialized = JSON.stringify(bounded);
|
||||
while (serialized.length > 4000 && bounded.windows.length > 0) {
|
||||
bounded.windows.pop();
|
||||
serialized = JSON.stringify(bounded);
|
||||
}
|
||||
return {
|
||||
eventType: "provider.notice.recorded",
|
||||
itemId,
|
||||
payload: {
|
||||
schema: "paperclip.provider.notice.v1",
|
||||
noticeId: itemId,
|
||||
severity: snapshot.limitReached ? "warning" : "info",
|
||||
category: PROVIDER_RATE_LIMITS_CATEGORY,
|
||||
scope: "account",
|
||||
recoverable: true,
|
||||
userActionable: false,
|
||||
summary: summary(snapshot).slice(0, 4000),
|
||||
details: [{ name: PROVIDER_RATE_LIMITS_DETAIL, value: serialized }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -954,6 +954,7 @@ export const LIVE_EVENT_TYPES = [
|
||||
"heartbeat.run.activity",
|
||||
"agent.session.goal.changed",
|
||||
"agent.status",
|
||||
"agent.runtime_state.updated",
|
||||
"activity.logged",
|
||||
"external_object.updated",
|
||||
"plugin.ui.updated",
|
||||
|
||||
@@ -2888,3 +2888,4 @@ export {
|
||||
export { readProviderFailure, type ProviderFailure } from "./provider-failure.generated.js";
|
||||
export type { BoardBootstrap, BoardBootstrapSession } from "./types/board-bootstrap.js";
|
||||
export { readBoardBootstrap } from "./board-bootstrap.js";
|
||||
export * from "./provider-rate-limits.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeProviderRateLimitSnapshots,
|
||||
parseProviderRateLimitSnapshot,
|
||||
providerRateLimitSnapshotFromNotice,
|
||||
summarizeProviderRateLimits,
|
||||
type ProviderRateLimitSnapshot,
|
||||
} from "./provider-rate-limits.js";
|
||||
|
||||
const NOW = Date.parse("2026-11-05T15:18:00.000Z");
|
||||
const base: ProviderRateLimitSnapshot = {
|
||||
schema: "paperclip.provider.rate_limits.v1",
|
||||
provider: "openai",
|
||||
windows: [
|
||||
{ id: "codex.primary", label: "5-hour window", shortLabel: "5h", windowMinutes: 300, usedPercent: 78, resetsAt: "2026-11-05T15:40:00.000Z", status: null },
|
||||
{ id: "codex.secondary", label: "Weekly", shortLabel: "7d", windowMinutes: 10080, usedPercent: 41, resetsAt: "2026-11-09T09:00:00.000Z", status: null },
|
||||
],
|
||||
credits: null,
|
||||
limitReached: null,
|
||||
observedAt: "2026-11-05T15:17:00.000Z",
|
||||
};
|
||||
|
||||
describe("provider rate limits", () => {
|
||||
it("summarizes the fullest current window and its tone", () => {
|
||||
expect(summarizeProviderRateLimits(base, NOW)).toMatchObject({ tone: "normal", limited: false, window: { id: "codex.primary" } });
|
||||
const warm = { ...base, windows: [{ ...base.windows[0]!, usedPercent: 86 }, base.windows[1]!] };
|
||||
expect(summarizeProviderRateLimits(warm, NOW)?.tone).toBe("warn");
|
||||
});
|
||||
|
||||
it("drops windows whose reset has passed", () => {
|
||||
const later = Date.parse("2026-11-05T16:00:00.000Z");
|
||||
expect(summarizeProviderRateLimits(base, later)?.window?.id).toBe("codex.secondary");
|
||||
expect(summarizeProviderRateLimits(base, Date.parse("2026-11-10T00:00:00.000Z"))).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the limited state until the reset", () => {
|
||||
const limited = { ...base, limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z" } };
|
||||
expect(summarizeProviderRateLimits(limited, NOW)).toMatchObject({ tone: "reached", limited: true, limitedUntil: "2026-11-05T15:40:00.000Z" });
|
||||
expect(summarizeProviderRateLimits(limited, Date.parse("2026-11-05T15:41:00.000Z"))?.limited).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a used-up window with credits as working on credits, not limited", () => {
|
||||
const onCredits = {
|
||||
...base,
|
||||
windows: [{ ...base.windows[1]!, id: "codex.primary", usedPercent: 100 }],
|
||||
credits: { hasCredits: true, unlimited: false, balance: null },
|
||||
limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-09T09:00:00.000Z" },
|
||||
};
|
||||
expect(summarizeProviderRateLimits(onCredits, NOW)).toMatchObject({ tone: "warn", limited: false, usingCredits: true });
|
||||
});
|
||||
|
||||
it("reads the snapshot from a rate-limit notice and rejects other shapes", () => {
|
||||
const payload = {
|
||||
schema: "paperclip.provider.notice.v1", category: "provider.rate_limits",
|
||||
details: [{ name: "rateLimits", value: JSON.stringify(base) }],
|
||||
};
|
||||
expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", payload)).toEqual(base);
|
||||
expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", { ...payload, category: "warning" })).toBeNull();
|
||||
expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", { ...payload, details: [{ name: "rateLimits", value: "{" }] })).toBeNull();
|
||||
expect(parseProviderRateLimitSnapshot({ ...base, provider: "google" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps known fields and another window's current rejection when merging", () => {
|
||||
const rejected: ProviderRateLimitSnapshot = {
|
||||
...base,
|
||||
provider: "anthropic",
|
||||
windows: [{ id: "claude.five_hour", label: "5-hour window", shortLabel: "5h", windowMinutes: 300, usedPercent: 100, resetsAt: "2026-11-05T15:40:00.000Z", status: "rejected" }],
|
||||
limitReached: { windowId: "claude.five_hour", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z" },
|
||||
};
|
||||
const weekly: ProviderRateLimitSnapshot = {
|
||||
...rejected,
|
||||
windows: [{ id: "claude.seven_day", label: "Weekly", shortLabel: "7d", windowMinutes: 10080, usedPercent: 40, resetsAt: null, status: "allowed" }],
|
||||
limitReached: null,
|
||||
observedAt: "2026-11-05T15:20:00.000Z",
|
||||
};
|
||||
const merged = mergeProviderRateLimitSnapshots(rejected, weekly);
|
||||
expect(merged.limitReached?.windowId).toBe("claude.five_hour");
|
||||
const sparse = { ...weekly, windows: [{ ...rejected.windows[0]!, usedPercent: null, resetsAt: null, status: "warning" as const }], observedAt: "2026-11-05T15:21:00.000Z" };
|
||||
const again = mergeProviderRateLimitSnapshots(merged, sparse);
|
||||
expect(again.windows.find((w) => w.id === "claude.five_hour")).toMatchObject({ usedPercent: 100, resetsAt: "2026-11-05T15:40:00.000Z", status: "warning" });
|
||||
expect(again.limitReached).toBeNull();
|
||||
});
|
||||
|
||||
it("merges by window and ignores out-of-order snapshots", () => {
|
||||
const next = { ...base, windows: [{ ...base.windows[0]!, usedPercent: 90 }], observedAt: "2026-11-05T15:20:00.000Z" };
|
||||
expect(mergeProviderRateLimitSnapshots(base, next).windows.map((w) => w.usedPercent)).toEqual([90, 41]);
|
||||
expect(mergeProviderRateLimitSnapshots(next, base)).toBe(next);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Latest provider rate-limit windows an agent's harness reported (Codex
|
||||
* `account/rateLimits/updated`, Claude `rate_limit_event`). The runner carries a
|
||||
* snapshot as one JSON detail on a `provider.notice.recorded` event with
|
||||
* category {@link PROVIDER_RATE_LIMITS_NOTICE_CATEGORY}; the server keeps the
|
||||
* merged latest snapshot in `agent_runtime_state.state_json` under
|
||||
* {@link PROVIDER_RATE_LIMITS_STATE_KEY}.
|
||||
*/
|
||||
export const PROVIDER_RATE_LIMITS_NOTICE_CATEGORY = "provider.rate_limits";
|
||||
export const PROVIDER_RATE_LIMITS_SCHEMA = "paperclip.provider.rate_limits.v1";
|
||||
export const PROVIDER_RATE_LIMITS_NOTICE_DETAIL = "rateLimits";
|
||||
export const PROVIDER_RATE_LIMITS_STATE_KEY = "providerRateLimits";
|
||||
export const PROVIDER_RATE_LIMIT_WARN_PERCENT = 80;
|
||||
|
||||
export type ProviderRateLimitProvider = "openai" | "anthropic";
|
||||
|
||||
export interface ProviderRateLimitWindow {
|
||||
id: string;
|
||||
/** "5-hour window", "Weekly", "Weekly (Opus)" */
|
||||
label: string;
|
||||
/** "5h", "7d" */
|
||||
shortLabel: string;
|
||||
windowMinutes: number | null;
|
||||
usedPercent: number | null;
|
||||
resetsAt: string | null;
|
||||
status: "allowed" | "warning" | "rejected" | null;
|
||||
}
|
||||
|
||||
export interface ProviderRateLimitSnapshot {
|
||||
schema: typeof PROVIDER_RATE_LIMITS_SCHEMA;
|
||||
provider: ProviderRateLimitProvider;
|
||||
windows: ProviderRateLimitWindow[];
|
||||
credits: { hasCredits: boolean | null; unlimited: boolean | null; balance: string | null } | null;
|
||||
limitReached: { windowId: string | null; reason: string; resetsAt: string | null } | null;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const obj = (value: unknown): Obj | null =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : null;
|
||||
const str = (value: unknown, max = 160): string | null =>
|
||||
typeof value === "string" && value.length > 0 ? value.slice(0, max) : null;
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
const iso = (value: unknown): string | null => {
|
||||
const text = str(value, 64);
|
||||
return text && Number.isFinite(Date.parse(text)) ? text : null;
|
||||
};
|
||||
const bool = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null);
|
||||
|
||||
/** Validate an untrusted snapshot (from a run event or stored state). */
|
||||
export function parseProviderRateLimitSnapshot(value: unknown): ProviderRateLimitSnapshot | null {
|
||||
const raw = obj(value);
|
||||
if (!raw || raw.schema !== PROVIDER_RATE_LIMITS_SCHEMA) return null;
|
||||
if (raw.provider !== "openai" && raw.provider !== "anthropic") return null;
|
||||
const observedAt = iso(raw.observedAt);
|
||||
if (!observedAt || !Array.isArray(raw.windows)) return null;
|
||||
const windows: ProviderRateLimitWindow[] = [];
|
||||
for (const entry of raw.windows.slice(0, 8)) {
|
||||
const window = obj(entry);
|
||||
const id = str(window?.id);
|
||||
const label = str(window?.label);
|
||||
if (!window || !id || !label) continue;
|
||||
const status = window.status === "allowed" || window.status === "warning" || window.status === "rejected" ? window.status : null;
|
||||
const usedPercent = num(window.usedPercent);
|
||||
const windowMinutes = num(window.windowMinutes);
|
||||
windows.push({
|
||||
id,
|
||||
label,
|
||||
shortLabel: str(window.shortLabel, 40) ?? label,
|
||||
windowMinutes: windowMinutes !== null && windowMinutes > 0 ? windowMinutes : null,
|
||||
usedPercent: usedPercent === null ? null : Math.min(1000, Math.max(0, usedPercent)),
|
||||
resetsAt: iso(window.resetsAt),
|
||||
status,
|
||||
});
|
||||
}
|
||||
const credits = obj(raw.credits);
|
||||
const reached = obj(raw.limitReached);
|
||||
return {
|
||||
schema: PROVIDER_RATE_LIMITS_SCHEMA,
|
||||
provider: raw.provider,
|
||||
windows,
|
||||
credits: credits
|
||||
? { hasCredits: bool(credits.hasCredits), unlimited: bool(credits.unlimited), balance: str(credits.balance, 40) }
|
||||
: null,
|
||||
limitReached: reached
|
||||
? { windowId: str(reached.windowId), reason: str(reached.reason) ?? "rate_limit_reached", resetsAt: iso(reached.resetsAt) }
|
||||
: null,
|
||||
observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** The snapshot carried by a rate-limit `provider.notice.recorded` payload, if any. */
|
||||
export function providerRateLimitSnapshotFromNotice(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
): ProviderRateLimitSnapshot | null {
|
||||
if (eventType !== "provider.notice.recorded") return null;
|
||||
if (payload.schema !== "paperclip.provider.notice.v1" || payload.category !== PROVIDER_RATE_LIMITS_NOTICE_CATEGORY) return null;
|
||||
const details = Array.isArray(payload.details) ? payload.details : [];
|
||||
const detail = details.map(obj).find((entry) => entry?.name === PROVIDER_RATE_LIMITS_NOTICE_DETAIL);
|
||||
if (typeof detail?.value !== "string") return null;
|
||||
try {
|
||||
return parseProviderRateLimitSnapshot(JSON.parse(detail.value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isProviderRateLimitNotice(eventType: string, payload: Record<string, unknown>): boolean {
|
||||
return eventType === "provider.notice.recorded"
|
||||
&& payload.schema === "paperclip.provider.notice.v1"
|
||||
&& payload.category === PROVIDER_RATE_LIMITS_NOTICE_CATEGORY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a newer snapshot into the stored one. Runners already merge within a
|
||||
* session; this keeps windows another session reported and ignores
|
||||
* out-of-order deliveries.
|
||||
*/
|
||||
export function mergeProviderRateLimitSnapshots(
|
||||
stored: ProviderRateLimitSnapshot | null,
|
||||
next: ProviderRateLimitSnapshot,
|
||||
): ProviderRateLimitSnapshot {
|
||||
if (!stored || stored.provider !== next.provider) return next;
|
||||
const at = Date.parse(next.observedAt);
|
||||
if (at < Date.parse(stored.observedAt)) return stored;
|
||||
// Claude reports one window per event and Codex updates are sparse: a null
|
||||
// field never clears a known value.
|
||||
const windows = new Map(stored.windows.map((window) => [window.id, window]));
|
||||
for (const window of next.windows) {
|
||||
const prior = windows.get(window.id);
|
||||
windows.set(window.id, prior ? {
|
||||
...window,
|
||||
usedPercent: window.usedPercent ?? prior.usedPercent,
|
||||
resetsAt: window.resetsAt ?? prior.resetsAt,
|
||||
windowMinutes: window.windowMinutes ?? prior.windowMinutes,
|
||||
status: window.status ?? prior.status,
|
||||
} : window);
|
||||
}
|
||||
// A report about another window does not clear a still-current rejection.
|
||||
const reportedIds = new Set(next.windows.map((window) => window.id));
|
||||
const storedReached = stored.limitReached;
|
||||
const keepStoredReached = storedReached !== null
|
||||
&& storedReached.windowId !== null
|
||||
&& !reportedIds.has(storedReached.windowId)
|
||||
&& (storedReached.resetsAt === null || Date.parse(storedReached.resetsAt) > at);
|
||||
return {
|
||||
...next,
|
||||
windows: [...windows.values()].slice(0, 8),
|
||||
credits: next.credits ?? stored.credits,
|
||||
limitReached: next.limitReached ?? (keepStoredReached ? storedReached : null),
|
||||
};
|
||||
}
|
||||
|
||||
export function readProviderRateLimits(stateJson: Record<string, unknown> | null | undefined): ProviderRateLimitSnapshot | null {
|
||||
return parseProviderRateLimitSnapshot(stateJson?.[PROVIDER_RATE_LIMITS_STATE_KEY]);
|
||||
}
|
||||
|
||||
export interface ProviderRateLimitSummary {
|
||||
tone: "normal" | "warn" | "reached";
|
||||
/** The window the one-line summary describes. */
|
||||
window: ProviderRateLimitWindow | null;
|
||||
/** Set while the provider is refusing work and the reset is still ahead. */
|
||||
limitedUntil: string | null;
|
||||
limited: boolean;
|
||||
/**
|
||||
* The provider reports a window as used up but the account has credits, so
|
||||
* work continues on credits (Codex) instead of waiting for the reset.
|
||||
*/
|
||||
usingCredits: boolean;
|
||||
/** Windows whose reset has not passed yet, fullest first. */
|
||||
current: ProviderRateLimitWindow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which window to show, given the clock. A window whose reset time has passed
|
||||
* has rolled over, so its stored percentage no longer applies.
|
||||
*/
|
||||
export function summarizeProviderRateLimits(
|
||||
snapshot: ProviderRateLimitSnapshot | null,
|
||||
now: number,
|
||||
): ProviderRateLimitSummary | null {
|
||||
if (!snapshot) return null;
|
||||
const live = (resetsAt: string | null) => !resetsAt || Date.parse(resetsAt) > now;
|
||||
const current = snapshot.windows
|
||||
.filter((window) => window.usedPercent !== null && live(window.resetsAt))
|
||||
.sort((a, b) => (b.usedPercent ?? 0) - (a.usedPercent ?? 0));
|
||||
const reached = snapshot.limitReached;
|
||||
const hasCredits = snapshot.credits?.unlimited === true || snapshot.credits?.hasCredits === true;
|
||||
const reachedNow = reached !== null && live(reached.resetsAt)
|
||||
&& (reached.resetsAt !== null || now - Date.parse(snapshot.observedAt) < 3_600_000);
|
||||
if (reached && reachedNow && !hasCredits) {
|
||||
const window = snapshot.windows.find((candidate) => candidate.id === reached.windowId) ?? current[0] ?? null;
|
||||
return { tone: "reached", window, limitedUntil: reached.resetsAt, limited: true, usingCredits: false, current };
|
||||
}
|
||||
const window = current[0] ?? null;
|
||||
if (!window) return null;
|
||||
const pct = window.usedPercent ?? 0;
|
||||
const usingCredits = hasCredits && (reachedNow || pct >= 100);
|
||||
return {
|
||||
tone: usingCredits ? "warn" : pct >= 100 ? "reached" : pct > PROVIDER_RATE_LIMIT_WARN_PERCENT ? "warn" : "normal",
|
||||
window,
|
||||
limitedUntil: null,
|
||||
limited: false,
|
||||
usingCredits,
|
||||
current,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agentRuntimeState, agents, companies, createDb } from "@paperclipai/db";
|
||||
import { readProviderRateLimits, type ProviderRateLimitSnapshot } from "@paperclipai/shared";
|
||||
import { recordAgentProviderRateLimits, preservedAccountRuntimeState } from "../services/agent-provider-rate-limits.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { recoveryPostgresSupport, startRecoveryPostgres } from "./helpers/recovery-postgres.js";
|
||||
|
||||
const describePostgres = recoveryPostgresSupport.supported ? describe : describe.skip;
|
||||
if (!recoveryPostgresSupport.supported) console.warn(`Skipping provider rate limit tests: ${recoveryPostgresSupport.reason}`);
|
||||
|
||||
function snapshot(input: Partial<ProviderRateLimitSnapshot> & { observedAt: string }): ProviderRateLimitSnapshot {
|
||||
return {
|
||||
schema: "paperclip.provider.rate_limits.v1",
|
||||
provider: "openai",
|
||||
windows: [],
|
||||
credits: null,
|
||||
limitReached: null,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function notice(value: ProviderRateLimitSnapshot) {
|
||||
return {
|
||||
eventType: "provider.notice.recorded",
|
||||
payload: {
|
||||
schema: "paperclip.provider.notice.v1",
|
||||
noticeId: "codex-rate-limits:1",
|
||||
severity: "info",
|
||||
category: "provider.rate_limits",
|
||||
scope: "account",
|
||||
recoverable: true,
|
||||
userActionable: false,
|
||||
summary: "Codex rate limits",
|
||||
details: [{ name: "rateLimits", value: JSON.stringify(value) }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fiveHour = (usedPercent: number) => ({
|
||||
id: "codex.primary", label: "5-hour window", shortLabel: "5h", windowMinutes: 300,
|
||||
usedPercent, resetsAt: "2026-11-05T15:40:00.000Z", status: null,
|
||||
});
|
||||
const weekly = (usedPercent: number) => ({
|
||||
id: "codex.secondary", label: "Weekly", shortLabel: "7d", windowMinutes: 10080,
|
||||
usedPercent, resetsAt: "2026-11-09T09:00:00.000Z", status: null,
|
||||
});
|
||||
|
||||
/** The latest provider rate-limit snapshot is kept per agent, in its own company. */
|
||||
describePostgres("agent provider rate limits", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startRecoveryPostgres>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startRecoveryPostgres("agent-provider-rate-limits-");
|
||||
db = createDb(temporary.connectionString);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
});
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Rate limit fixture", issuePrefix: `R${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Codex planner", adapterType: "paperclip_runner", status: "idle", adapterConfig: {} });
|
||||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
async function stored(agentId: string) {
|
||||
const row = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, agentId)).then((rows) => rows[0]);
|
||||
return row ? readProviderRateLimits(row.stateJson) : null;
|
||||
}
|
||||
|
||||
it("creates runtime state and merges later snapshots by window", async () => {
|
||||
const binding = await seed();
|
||||
await expect(recordAgentProviderRateLimits(db, binding, notice(snapshot({
|
||||
observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(70), weekly(40)],
|
||||
})))).resolves.toBe(true);
|
||||
await recordAgentProviderRateLimits(db, binding, notice(snapshot({
|
||||
observedAt: "2026-11-05T15:10:00.000Z", windows: [fiveHour(78)],
|
||||
})));
|
||||
expect((await stored(binding.agentId))?.windows.map((window) => [window.id, window.usedPercent])).toEqual([
|
||||
["codex.primary", 78],
|
||||
["codex.secondary", 40],
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges concurrent reports for one agent without losing a window", async () => {
|
||||
const binding = await seed();
|
||||
await Promise.all([
|
||||
recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(70)] }))),
|
||||
recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:01.000Z", windows: [weekly(40)] }))),
|
||||
]);
|
||||
expect((await stored(binding.agentId))?.windows.map((window) => window.id).sort()).toEqual(["codex.primary", "codex.secondary"]);
|
||||
});
|
||||
|
||||
it("reports no change when the same notice is replayed", async () => {
|
||||
const binding = await seed();
|
||||
const value = notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(70)] }));
|
||||
await expect(recordAgentProviderRateLimits(db, binding, value)).resolves.toBe(true);
|
||||
await expect(recordAgentProviderRateLimits(db, binding, value)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("ignores an older snapshot delivered late", async () => {
|
||||
const binding = await seed();
|
||||
await recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:10:00.000Z", windows: [fiveHour(78)] })));
|
||||
await recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(60)] })));
|
||||
expect((await stored(binding.agentId))?.windows[0]?.usedPercent).toBe(78);
|
||||
});
|
||||
|
||||
it("does not write for another company's agent or for unrelated notices", async () => {
|
||||
const binding = await seed();
|
||||
const other = await seed();
|
||||
await expect(recordAgentProviderRateLimits(db, { companyId: other.companyId, agentId: binding.agentId }, notice(snapshot({
|
||||
observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(10)],
|
||||
})))).resolves.toBe(false);
|
||||
await expect(recordAgentProviderRateLimits(db, binding, {
|
||||
eventType: "provider.notice.recorded",
|
||||
payload: { schema: "paperclip.provider.notice.v1", category: "warning", details: [] },
|
||||
})).resolves.toBe(false);
|
||||
expect(await stored(binding.agentId)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the snapshot when the board resets the agent's session", async () => {
|
||||
const binding = await seed();
|
||||
await recordAgentProviderRateLimits(db, binding, notice(snapshot({ observedAt: "2026-11-05T15:00:00.000Z", windows: [fiveHour(55)] })));
|
||||
await db.update(agentRuntimeState)
|
||||
.set({ stateJson: { ...(await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, binding.agentId)).then((rows) => rows[0]!.stateJson)), sessionScratch: true } })
|
||||
.where(eq(agentRuntimeState.agentId, binding.agentId));
|
||||
await heartbeatService(db).resetRuntimeSession(binding.agentId);
|
||||
const row = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, binding.agentId)).then((rows) => rows[0]!);
|
||||
expect(row.stateJson.sessionScratch).toBeUndefined();
|
||||
expect(readProviderRateLimits(row.stateJson)?.windows[0]?.usedPercent).toBe(55);
|
||||
});
|
||||
|
||||
it("preserves only account keys", () => {
|
||||
expect(preservedAccountRuntimeState({ providerRateLimits: { a: 1 }, other: 2 })).toEqual({ providerRateLimits: { a: 1 } });
|
||||
expect(preservedAccountRuntimeState(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -278,6 +278,11 @@ describe("live-event run read boundaries", () => {
|
||||
await once(socket, "open");
|
||||
socket.send(JSON.stringify({ type: "live.interests", version: 1, revision: 1, runIds: [] }));
|
||||
await expect.poll(() => messages.some(message => message.type === "live.subscribed" && message.revision === 1)).toBe(true);
|
||||
// Runtime-state refreshes reach the company without a transcript subscription.
|
||||
const refreshedAgentId = randomUUID();
|
||||
publishLiveEvent({ companyId, type: "agent.runtime_state.updated", payload: { agentId: refreshedAgentId } });
|
||||
await barrier();
|
||||
expect(messages.find(message => message.type === "agent.runtime_state.updated")?.payload).toMatchObject({ agentId: refreshedAgentId });
|
||||
const baseline = queries.length;
|
||||
publishLiveEvent({ companyId, type: "heartbeat.run.event", payload: sourcePayload }); await barrier();
|
||||
expect(messages.some(message => message.type === "heartbeat.run.event")).toBe(false);
|
||||
|
||||
@@ -355,6 +355,7 @@ export function setupLiveEventsWebSocketServer(
|
||||
...(issueId ? await activityToastIdentity(db, event.companyId, payload) : {}),
|
||||
} };
|
||||
if (event.type === "agent.status") return { ...event, payload: { agentId: payload.agentId, status: payload.status } };
|
||||
if (event.type === "agent.runtime_state.updated") return { ...event, payload: { agentId: payload.agentId } };
|
||||
if (event.type === "external_object.updated") return { ...event, payload: { externalObjectId: payload.externalObjectId } };
|
||||
if (event.type.startsWith("plugin.")) return event;
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentRuntimeState, agents } from "@paperclipai/db";
|
||||
import {
|
||||
PROVIDER_RATE_LIMITS_STATE_KEY,
|
||||
mergeProviderRateLimitSnapshots,
|
||||
providerRateLimitSnapshotFromNotice,
|
||||
readProviderRateLimits,
|
||||
} from "@paperclipai/shared";
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
|
||||
/**
|
||||
* Keep the latest provider rate-limit snapshot an agent's harness reported.
|
||||
* The snapshot is account data the harness observed while running this agent;
|
||||
* it lives in the agent's company-scoped runtime state and never drives
|
||||
* execution (the provider-limit wait reads typed run failures instead).
|
||||
*
|
||||
* Safe to repeat: replaying the same notice merges to the same snapshot.
|
||||
* Returns true when the stored snapshot changed.
|
||||
*/
|
||||
export async function recordAgentProviderRateLimits(
|
||||
db: Db,
|
||||
binding: { companyId: string; agentId: string },
|
||||
event: { eventType: string; payload: Record<string, unknown> },
|
||||
): Promise<boolean> {
|
||||
const next = providerRateLimitSnapshotFromNotice(event.eventType, event.payload);
|
||||
if (!next) return false;
|
||||
const changed = await db.transaction(async (tx) => {
|
||||
const agent = await tx
|
||||
.select({ adapterType: agents.adapterType })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, binding.agentId), eq(agents.companyId, binding.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent) return false;
|
||||
await tx
|
||||
.insert(agentRuntimeState)
|
||||
.values({ agentId: binding.agentId, companyId: binding.companyId, adapterType: agent.adapterType, stateJson: {} })
|
||||
.onConflictDoNothing({ target: agentRuntimeState.agentId });
|
||||
// Lock the row so concurrent runs merge in order instead of overwriting.
|
||||
const existing = await tx
|
||||
.select({ stateJson: agentRuntimeState.stateJson })
|
||||
.from(agentRuntimeState)
|
||||
.where(and(eq(agentRuntimeState.agentId, binding.agentId), eq(agentRuntimeState.companyId, binding.companyId)))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return false;
|
||||
const stored = readProviderRateLimits(existing.stateJson);
|
||||
const merged = mergeProviderRateLimitSnapshots(stored, next);
|
||||
if (stored && JSON.stringify(stored) === JSON.stringify(merged)) return false;
|
||||
const patch = JSON.stringify({ [PROVIDER_RATE_LIMITS_STATE_KEY]: merged });
|
||||
await tx
|
||||
.update(agentRuntimeState)
|
||||
.set({ stateJson: sql`coalesce(${agentRuntimeState.stateJson}, '{}'::jsonb) || ${patch}::jsonb` })
|
||||
.where(and(eq(agentRuntimeState.agentId, binding.agentId), eq(agentRuntimeState.companyId, binding.companyId)));
|
||||
return true;
|
||||
});
|
||||
if (changed) {
|
||||
publishLiveEvent({
|
||||
companyId: binding.companyId,
|
||||
type: "agent.runtime_state.updated",
|
||||
payload: { agentId: binding.agentId },
|
||||
});
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Runtime state keys that describe the provider account, not the session. */
|
||||
export function preservedAccountRuntimeState(stateJson: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
const snapshot = stateJson?.[PROVIDER_RATE_LIMITS_STATE_KEY];
|
||||
return snapshot === undefined ? {} : { [PROVIDER_RATE_LIMITS_STATE_KEY]: snapshot };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyExternalConversationSettlement } from "../external-conversation-settlement-notifications.js";
|
||||
import { preservedAccountRuntimeState } from "../agent-provider-rate-limits.js";
|
||||
import { readRuntimeAccountingTotals } from "../runtime-accounting-totals.js";
|
||||
import { conversationEventCondition, conversationEventColumns } from "./conversation-events.js";
|
||||
import { deriveTaskKey } from "./run-preparation.js";
|
||||
@@ -1940,7 +1941,7 @@ export function createHeartbeatRunState(db: Db) {
|
||||
) => {
|
||||
const agent = await getAgent(agentId);
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
await ensureRuntimeState(agent);
|
||||
const runtimeBefore = await ensureRuntimeState(agent);
|
||||
const taskKey = readNonEmptyString(opts?.taskKey);
|
||||
const clearedTaskSessions = await clearTaskSessions(
|
||||
agent.companyId,
|
||||
@@ -1959,7 +1960,8 @@ export function createHeartbeatRunState(db: Db) {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (!taskKey) {
|
||||
runtimePatch.stateJson = {};
|
||||
// Provider rate limits describe the account, not the session.
|
||||
runtimePatch.stateJson = preservedAccountRuntimeState(runtimeBefore.stateJson);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NativeSessionDiagnostics, nativeSessionDiagnostics, type NativeDiagnosticLease } from "./native-session-diagnostics.js";
|
||||
import { recordAgentProviderRateLimits } from "../agent-provider-rate-limits.js";
|
||||
import { nativePostResultFailureCategory, type NativePostResultFailureCategory } from "./native-post-result-failure.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { canRestoreMigratedNativeSession } from "./native-workspace-migration.js";
|
||||
@@ -9215,6 +9216,16 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
throw error;
|
||||
}
|
||||
const controlPlaneInstanceId = `${effectiveRunnerInstanceId}:control`;
|
||||
// Display data only; a failure never fails the run. The replay path repeats
|
||||
// it so a snapshot lost between the event commit and this write recovers.
|
||||
const recordRateLimits = async (event: unknown) => {
|
||||
await recordAgentProviderRateLimits(input.db, input.execution.binding, event as {
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error, runId: input.execution.binding.runId }, "failed to record provider rate limits");
|
||||
});
|
||||
};
|
||||
const planSynchronizations: PlanSynchronization[] = [];
|
||||
const upsertPlanSynchronization = (
|
||||
synchronization: PlanSynchronization,
|
||||
@@ -9682,6 +9693,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
event,
|
||||
event.eventType === "item.completed" || questionFallback !== null,
|
||||
);
|
||||
await recordRateLimits(event);
|
||||
await recordPlanSynchronization(
|
||||
event as {
|
||||
sourceEventId: string;
|
||||
@@ -9708,6 +9720,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
event,
|
||||
event.eventType === "item.completed" || questionFallback !== null,
|
||||
);
|
||||
await recordRateLimits(event);
|
||||
const planSynchronization = await synchronizeCompletedProviderPlan({
|
||||
db: input.db,
|
||||
execution: input.execution,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ChevronRight, Clock } from "lucide-react";
|
||||
import {
|
||||
summarizeProviderRateLimits,
|
||||
type ProviderRateLimitSnapshot,
|
||||
type ProviderRateLimitSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tone = ProviderRateLimitSummary["tone"];
|
||||
|
||||
const fillClass: Record<Tone, string> = {
|
||||
normal: "bg-muted-foreground/60",
|
||||
warn: "bg-(--status-task-todo)",
|
||||
reached: "bg-destructive",
|
||||
};
|
||||
|
||||
const textClass: Record<Tone, string> = {
|
||||
normal: "text-foreground",
|
||||
warn: "text-(--status-task-icon-todo)",
|
||||
reached: "text-destructive",
|
||||
};
|
||||
|
||||
export function formatRateLimitClock(iso: string, now: number): string {
|
||||
const at = new Date(iso);
|
||||
const time = at.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
if (new Date(now).toDateString() === at.toDateString()) return time;
|
||||
if (Math.abs(at.getTime() - now) < 6 * 86_400_000) return `${at.toLocaleDateString("en-US", { weekday: "short" })} ${time}`;
|
||||
return `${at.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${time}`;
|
||||
}
|
||||
|
||||
function Bar({ pct, tone, className }: { pct: number; tone: Tone; className?: string }) {
|
||||
const clamped = Math.min(100, Math.max(0, pct));
|
||||
return (
|
||||
<span className={cn("relative inline-block h-1.5 overflow-hidden rounded-full bg-muted", className)} aria-hidden>
|
||||
<span className={cn("absolute inset-y-0 left-0 rounded-full", fillClass[tone])} style={{ width: `${clamped}%` }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function windowTone(pct: number, reached: boolean): Tone {
|
||||
if (reached || pct >= 100) return "reached";
|
||||
return pct > 80 ? "warn" : "normal";
|
||||
}
|
||||
|
||||
/** Every current window as a labelled bar; shown inside the property popover. */
|
||||
export function AgentRateLimitPanel({ snapshot, summary, now }: {
|
||||
snapshot: ProviderRateLimitSnapshot;
|
||||
summary: ProviderRateLimitSummary;
|
||||
now: number;
|
||||
}) {
|
||||
const reachedId = summary.limited ? snapshot.limitReached?.windowId ?? null : null;
|
||||
const creditTone = summary.usingCredits ? "warn" : null;
|
||||
const credits = snapshot.credits;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{summary.current.map((window) => {
|
||||
const pct = window.usedPercent ?? 0;
|
||||
const tone = window.id === summary.window?.id && creditTone ? creditTone : windowTone(pct, window.id === reachedId);
|
||||
return (
|
||||
<div key={window.id} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-3 text-xs">
|
||||
<span className="truncate text-muted-foreground">{window.label}</span>
|
||||
<span className={cn("shrink-0 tabular-nums", textClass[tone])}>
|
||||
{Math.round(pct)}%
|
||||
{window.resetsAt && (
|
||||
<span className="text-muted-foreground"> · resets {formatRateLimitClock(window.resetsAt, now)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Bar pct={pct} tone={tone} className="w-full" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{summary.usingCredits && (
|
||||
<div className="text-xs text-(--status-task-icon-todo)">Limit reached; work continues on account credits.</div>
|
||||
)}
|
||||
{credits && (credits.unlimited || credits.balance !== null || credits.hasCredits === false) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{credits.unlimited ? "Unlimited credits" : credits.hasCredits === false ? "No credits remaining" : `${credits.balance} credits`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line "Usage limit" property value: "5h 78% · resets 15:40", amber above
|
||||
* 80%, "Limited until 15:40" while the provider refuses work, "using credits"
|
||||
* when a used-up window falls back to account credits. Click opens the
|
||||
* full window breakdown. Renders nothing when no current limit data exists.
|
||||
*/
|
||||
export function AgentRateLimitPropertyValue({ snapshot, now = Date.now(), defaultOpen, detailsHref, className }: {
|
||||
snapshot: ProviderRateLimitSnapshot | null;
|
||||
now?: number;
|
||||
defaultOpen?: boolean;
|
||||
/** Link to a fuller usage view, e.g. the agent's page. */
|
||||
detailsHref?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const summary = summarizeProviderRateLimits(snapshot, now);
|
||||
if (!snapshot || !summary) return null;
|
||||
const { window, tone } = summary;
|
||||
const label = summary.limited
|
||||
? `Limited${summary.limitedUntil ? ` until ${formatRateLimitClock(summary.limitedUntil, now)}` : ""}`
|
||||
: summary.usingCredits
|
||||
? `${window!.shortLabel} ${Math.round(window!.usedPercent ?? 0)}% · using credits`
|
||||
: `${window!.shortLabel} ${Math.round(window!.usedPercent ?? 0)}%${window!.resetsAt ? ` · resets ${formatRateLimitClock(window!.resetsAt, now)}` : ""}`;
|
||||
return (
|
||||
<Popover defaultOpen={defaultOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group inline-flex min-w-0 items-center gap-1.5 rounded-sm text-xs tabular-nums hover:text-foreground",
|
||||
textClass[tone],
|
||||
className,
|
||||
)}
|
||||
aria-label={`Provider usage limit: ${label}`}
|
||||
>
|
||||
{summary.limited
|
||||
? <Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
: <Bar pct={window!.usedPercent ?? 0} tone={tone} className="w-6 shrink-0" />}
|
||||
<span className="truncate">{label}</span>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" aria-hidden />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 space-y-3 p-3">
|
||||
<div className="text-xs font-medium">{snapshot.provider === "openai" ? "Codex" : "Claude"} usage limits</div>
|
||||
<AgentRateLimitPanel snapshot={snapshot} summary={summary} now={now} />
|
||||
<div className="flex items-center justify-between gap-3 text-(length:--text-micro) text-muted-foreground">
|
||||
<span>Reported {formatRateLimitClock(snapshot.observedAt, now)}</span>
|
||||
{detailsHref && <Link to={detailsHref} className="hover:text-foreground hover:underline">View agent</Link>}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
deriveOriginatingActor,
|
||||
readProviderRateLimits,
|
||||
summarizeProviderRateLimits,
|
||||
isArtifactReviewDocumentKey,
|
||||
type ExecutionWorkspace,
|
||||
type Issue,
|
||||
@@ -69,7 +71,8 @@ import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags";
|
||||
import { Identity } from "../Identity";
|
||||
import { ProjectTile } from "../ProjectTile";
|
||||
import { IssueReferencePill } from "../IssueReferencePill";
|
||||
import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils";
|
||||
import { formatDate, formatDateTime, cn, projectUrl, agentUrl } from "../../lib/utils";
|
||||
import { AgentRateLimitPropertyValue } from "../AgentRateLimitGauge";
|
||||
import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects";
|
||||
import { timeAgo } from "../../lib/timeAgo";
|
||||
import { invalidateInboxIssueQueries } from "../../lib/inboxArchiveCache";
|
||||
@@ -427,6 +430,16 @@ export function IssueProperties({
|
||||
queryFn: () => agentsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const assigneeRuntimeAgentId = issue.assigneeAgentId ?? null;
|
||||
const { data: assigneeRuntimeState } = useQuery({
|
||||
queryKey: queryKeys.agents.runtimeState(assigneeRuntimeAgentId ?? ""),
|
||||
queryFn: () => agentsApi.runtimeState(assigneeRuntimeAgentId!, companyId ?? undefined),
|
||||
// Optional detail: a 403 for board users without agent management access
|
||||
// is not retried by the shared policy and simply hides the row.
|
||||
enabled: !!assigneeRuntimeAgentId && !!companyId,
|
||||
});
|
||||
const assigneeRateLimits = readProviderRateLimits(assigneeRuntimeState?.stateJson);
|
||||
const assigneeRateLimitAgent = assigneeRuntimeAgentId ? agents?.find((agent) => agent.id === assigneeRuntimeAgentId) : null;
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(companyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(companyId!),
|
||||
@@ -2403,6 +2416,15 @@ export function IssueProperties({
|
||||
</PropertyPicker>
|
||||
) : null}
|
||||
|
||||
{assigneeRateLimits && summarizeProviderRateLimits(assigneeRateLimits, Date.now()) ? (
|
||||
<PropertyRow label="Usage limit">
|
||||
<AgentRateLimitPropertyValue
|
||||
snapshot={assigneeRateLimits}
|
||||
detailsHref={assigneeRateLimitAgent ? agentUrl(assigneeRateLimitAgent) : undefined}
|
||||
/>
|
||||
</PropertyRow>
|
||||
) : null}
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Project"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { isProviderRateLimitNotice } from "@paperclipai/shared";
|
||||
|
||||
/** Internal provider bookkeeping belongs in run logs, including older notice records. */
|
||||
export function isRunLogOnlyProviderEvent(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
): boolean {
|
||||
// Rate-limit snapshots feed the agent's usage-limit property, not the transcript.
|
||||
if (isProviderRateLimitNotice(eventType, payload)) return true;
|
||||
if (eventType === "harness.diagnostic") {
|
||||
return payload.code === "codex_unrelated_information";
|
||||
}
|
||||
|
||||
@@ -1378,6 +1378,10 @@ function invalidateHeartbeatQueries(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.heartbeats(companyId, agentId),
|
||||
});
|
||||
// Provider rate limits reported during the run live in runtime state.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.runtimeState(agentId),
|
||||
});
|
||||
}
|
||||
const runId = readString(payload.runId);
|
||||
if (runId) {
|
||||
@@ -1988,6 +1992,13 @@ function handleLiveEvent(
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent.runtime_state.updated") {
|
||||
// The server saved a new provider rate-limit snapshot for this agent.
|
||||
const agentId = readString(payload.agentId);
|
||||
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent.status") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.list(expectedCompanyId),
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Agent, ProviderRateLimitSnapshot } from "@paperclipai/shared";
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { AgentRateLimitPropertyValue } from "@/components/AgentRateLimitGauge";
|
||||
import { IssueStatusBadge } from "@/components/StatusBadge";
|
||||
import { PropertyRow, PropertySection } from "@/components/issue-properties/primitives";
|
||||
|
||||
// Local-time ISO strings (no zone) keep the rendered clocks stable across machines.
|
||||
const NOW = new Date("2026-11-05T15:18:00").getTime();
|
||||
const at = (local: string) => new Date(local).toISOString();
|
||||
|
||||
const window = (id: string, label: string, shortLabel: string, windowMinutes: number, usedPercent: number, resetsAt: string) => ({
|
||||
id, label, shortLabel, windowMinutes, usedPercent, resetsAt: at(resetsAt), status: null,
|
||||
});
|
||||
|
||||
const codex: ProviderRateLimitSnapshot = {
|
||||
schema: "paperclip.provider.rate_limits.v1",
|
||||
provider: "openai",
|
||||
windows: [
|
||||
window("codex.primary", "5-hour window", "5h", 300, 78, "2026-11-05T15:40:00"),
|
||||
window("codex.secondary", "Weekly", "7d", 10080, 41, "2026-11-09T09:00:00"),
|
||||
],
|
||||
credits: null,
|
||||
limitReached: null,
|
||||
observedAt: at("2026-11-05T15:17:00"),
|
||||
};
|
||||
|
||||
const claude: ProviderRateLimitSnapshot = {
|
||||
schema: "paperclip.provider.rate_limits.v1",
|
||||
provider: "anthropic",
|
||||
windows: [
|
||||
window("claude.five_hour", "5-hour window", "5h", 300, 86, "2026-11-05T17:05:00"),
|
||||
window("claude.seven_day", "Weekly", "7d", 10080, 63, "2026-11-10T08:00:00"),
|
||||
window("claude.seven_day_opus", "Weekly (Opus)", "7d Opus", 10080, 52, "2026-11-10T08:00:00"),
|
||||
],
|
||||
credits: null,
|
||||
limitReached: null,
|
||||
observedAt: at("2026-11-05T15:16:00"),
|
||||
};
|
||||
|
||||
const limited: ProviderRateLimitSnapshot = {
|
||||
...codex,
|
||||
windows: [
|
||||
window("codex.primary", "5-hour window", "5h", 300, 100, "2026-11-05T15:40:00"),
|
||||
window("codex.secondary", "Weekly", "7d", 10080, 71, "2026-11-09T09:00:00"),
|
||||
],
|
||||
credits: { hasCredits: false, unlimited: false, balance: null },
|
||||
limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: at("2026-11-05T15:40:00") },
|
||||
observedAt: at("2026-11-05T15:02:00"),
|
||||
};
|
||||
|
||||
const agent = (id: string, name: string) => ({ id, name, companyId: "company-story" }) as unknown as Agent;
|
||||
|
||||
function Pane({ caption, assignee, model, snapshot, open }: {
|
||||
caption: string;
|
||||
assignee: Agent;
|
||||
model: string;
|
||||
snapshot: ProviderRateLimitSnapshot | null;
|
||||
open?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">{caption}</div>
|
||||
<div className="w-80 border border-border bg-background p-4">
|
||||
<PropertySection title="Properties" first streamlined>
|
||||
<PropertyRow label="Status"><IssueStatusBadge status="in_progress" /></PropertyRow>
|
||||
<PropertyRow label="Assignee"><AgentIdentity agent={assignee} size="sm" /></PropertyRow>
|
||||
<PropertyRow label="Model"><span className="truncate font-mono text-xs">{model}</span></PropertyRow>
|
||||
{snapshot && (
|
||||
<PropertyRow label="Usage limit">
|
||||
<AgentRateLimitPropertyValue snapshot={snapshot} now={NOW} defaultOpen={open} detailsHref="/agents/planner" />
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Project"><span className="truncate text-sm">Billing revamp</span></PropertyRow>
|
||||
</PropertySection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageLimitProperty() {
|
||||
return (
|
||||
<div className="flex items-start gap-6 p-6 pb-72">
|
||||
<Pane caption="Normal (Codex)" assignee={agent("agent-planner", "Planner")} model="gpt-5.6-sol" snapshot={codex} />
|
||||
<Pane caption="Above 80% (Claude)" assignee={agent("agent-engineer", "Founding Engineer")} model="claude-opus-5-5" snapshot={claude} />
|
||||
<Pane caption="Limit reached" assignee={agent("agent-reviewer", "Code Reviewer")} model="gpt-5.6-sol" snapshot={limited} />
|
||||
<Pane caption="Details popover" assignee={agent("agent-engineer", "Founding Engineer")} model="claude-opus-5-5" snapshot={claude} open />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "tasks-usage-limit-property",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Usage limit property",
|
||||
component: UsageLimitProperty,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The task properties pane shows the assignee's latest provider usage limit as one line: the fullest current window and its reset, amber above 80%, 'Limited until' while the provider refuses work. Clicking it lists every window. Agents on API-key auth report no limits and show no row.",
|
||||
},
|
||||
},
|
||||
paperclip: {
|
||||
capabilities: ["tasks/properties"],
|
||||
status: "current",
|
||||
implementation: "production",
|
||||
boundary: "fixtures",
|
||||
notes: "Renders the shipped AgentRateLimitPropertyValue inside the shared property primitives with a fixed clock. Snapshots mirror Codex account/rateLimits/updated and Claude rate_limit_event windows; the runtime-state query is not exercised.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof UsageLimitProperty>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Overview: Story = {};
|
||||
Reference in new issue
Block a user