Skip to main content

forge_agent/translate/
state_parsing.rs

1//! Stream-message state parsers: rate-limit / api-retry /
2//! runtime-session / settings-parse-error. Mirrors upstream's
3//! `agent-sdk/src/bridge/state_parsing.ts`.
4
5use serde_json::{Map, Value};
6
7use forge_primitives::{
8    ApiRetryError, RateLimitStatus, RuntimeSessionState, SettingsParseErrorUpdate,
9};
10
11// JSON walking helpers.
12
13fn record(value: &Value) -> Option<&Map<String, Value>> {
14    value.as_object()
15}
16
17fn number_field(record: &Map<String, Value>, keys: &[&str]) -> Option<f64> {
18    for key in keys {
19        if let Some(v) = record.get(*key).and_then(Value::as_f64)
20            && v.is_finite()
21        {
22            return Some(v);
23        }
24    }
25    None
26}
27
28/// Deserialize a wire-string enum variant from an optional JSON
29/// value. Returns `None` for missing field, wrong JSON type, or
30/// unrecognised variant - the three failure modes share a single
31/// drop path. Each enum carries `#[serde(rename_all = "snake_case")]`
32/// so this is just the typed wrapper.
33fn parse_enum<T: serde::de::DeserializeOwned>(value: Option<&Value>) -> Option<T> {
34    serde_json::from_value(value?.clone()).ok()
35}
36
37fn parse_rate_limit_status(value: Option<&Value>) -> Option<RateLimitStatus> {
38    parse_enum(value)
39}
40
41pub fn parse_runtime_session_state(value: Option<&Value>) -> Option<RuntimeSessionState> {
42    parse_enum(value)
43}
44
45/// Reads a numeric field as `u64`, returning `None` when the field
46/// is missing or its value is outside `[0, u64::MAX)` (note the open
47/// upper bound - see the body comment). Out-of-range drops emit a
48/// debug breadcrumb so a misbehaving CLI is observable.
49///
50/// `as u64` saturates rather than fails on out-of-range floats, so
51/// the explicit guard runs BEFORE the cast. The `>=` upper bound
52/// rejects `u64::MAX as f64` itself (which equals `2^64`, one past
53/// the largest representable u64) along with everything above.
54#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
55fn parse_clamped_u64(message: &Map<String, Value>, keys: &[&str]) -> Option<u64> {
56    let v = number_field(message, keys)?;
57    if v < 0.0 || v >= u64::MAX as f64 {
58        tracing::debug!(
59            target: crate::logging::targets::BRIDGE_LIFECYCLE,
60            raw = v,
61            keys = ?keys,
62            "dropping numeric field - value outside u64 range",
63        );
64        return None;
65    }
66    Some(v as u64)
67}
68
69/// Optional sibling of [`parse_clamped_u64`] for u16-sized fields
70/// (status codes). Field-absent is silent (`None`); out-of-range
71/// value logs at debug and returns `None`.
72#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
73fn parse_clamped_u16_optional(message: &Map<String, Value>, keys: &[&str]) -> Option<u16> {
74    let v = number_field(message, keys)?;
75    if v < 0.0 || v > f64::from(u16::MAX) {
76        // u16::MAX as f64 is exactly representable, so `>` (rather
77        // than `>=` like the u64 sibling) suffices here.
78        tracing::debug!(
79            target: crate::logging::targets::BRIDGE_LIFECYCLE,
80            raw = v,
81            keys = ?keys,
82            "dropping status field - value outside u16 range",
83        );
84        return None;
85    }
86    Some(v as u16)
87}
88
89fn parse_api_retry_error(value: Option<&Value>) -> ApiRetryError {
90    match value.and_then(Value::as_str) {
91        Some("authentication_failed") => ApiRetryError::AuthenticationFailed,
92        Some("billing_error") => ApiRetryError::BillingError,
93        Some("rate_limit") => ApiRetryError::RateLimit,
94        Some("invalid_request") => ApiRetryError::InvalidRequest,
95        Some("server_error") => ApiRetryError::ServerError,
96        Some("max_output_tokens") => ApiRetryError::MaxOutputTokens,
97        _ => ApiRetryError::Unknown,
98    }
99}
100
101/// Mirrors upstream's `buildRateLimitUpdate(rateLimitInfo)`. Returns a
102/// typed `RateLimitUpdate` when the input has a recognised `status`,
103/// populating optional fields when present.
104pub fn build_rate_limit_update(
105    rate_limit_info: Option<&Value>,
106) -> Option<forge_primitives::RateLimitUpdate> {
107    let info = rate_limit_info.and_then(record)?;
108    let status = parse_rate_limit_status(info.get("status"))?;
109
110    Some(forge_primitives::RateLimitUpdate {
111        status,
112        resets_at: number_field(info, &["resetsAt"]),
113        utilization: number_field(info, &["utilization"]),
114        rate_limit_type: info
115            .get("rateLimitType")
116            .and_then(Value::as_str)
117            .filter(|s| !s.is_empty())
118            .map(str::to_owned),
119        overage_status: parse_rate_limit_status(info.get("overageStatus")),
120        overage_resets_at: number_field(info, &["overageResetsAt"]),
121        overage_disabled_reason: info
122            .get("overageDisabledReason")
123            .and_then(Value::as_str)
124            .filter(|s| !s.is_empty())
125            .map(str::to_owned),
126        is_using_overage: info.get("isUsingOverage").and_then(Value::as_bool),
127        surpassed_threshold: number_field(info, &["surpassedThreshold"]),
128    })
129}
130
131/// Mirrors upstream's `buildApiRetryUpdate(message)`. Returns a typed
132/// `ApiRetryUpdate` when all three required numeric fields parse
133/// correctly.
134///
135/// Out-of-range values (negative or above `u64::MAX as f64` for
136/// the count fields, above `u16::MAX as f64` for the status field)
137/// are dropped via `parse_clamped_u64` / `parse_clamped_u16_optional`
138/// rather than allowed to saturate silently. `f64::is_finite()`
139/// upstream filters NaN/inf, so the helpers only see finite values
140/// to guard.
141pub fn build_api_retry_update(
142    message: &Map<String, Value>,
143) -> Option<forge_primitives::ApiRetryUpdate> {
144    let attempt = parse_clamped_u64(message, &["attempt"])?;
145    let max_retries = parse_clamped_u64(message, &["max_retries", "maxRetries"])?;
146    let retry_delay_ms = parse_clamped_u64(message, &["retry_delay_ms", "retryDelayMs"])?;
147    let error_status = parse_clamped_u16_optional(message, &["error_status", "errorStatus"]);
148    let error = parse_api_retry_error(message.get("error"));
149
150    Some(forge_primitives::ApiRetryUpdate {
151        attempt,
152        max_retries,
153        retry_delay_ms,
154        error_status,
155        error,
156    })
157}
158
159/// Mirrors `normalizeSettingsParseError(value)`.
160fn normalize_settings_parse_error(value: &Value) -> Option<SettingsParseErrorUpdate> {
161    let r = record(value)?;
162    let message = r.get("message").and_then(Value::as_str)?.trim();
163    if message.is_empty() {
164        return None;
165    }
166    let path = r.get("path").and_then(Value::as_str).unwrap_or("").to_owned();
167    let file =
168        r.get("file").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_owned);
169    Some(SettingsParseErrorUpdate { file, path, message: message.to_owned() })
170}
171
172/// Accepts either a single record or an array; returns all valid entries.
173pub fn normalize_settings_parse_errors(value: &Value) -> Vec<SettingsParseErrorUpdate> {
174    if let Some(arr) = value.as_array() {
175        return arr.iter().filter_map(normalize_settings_parse_error).collect();
176    }
177    normalize_settings_parse_error(value).into_iter().collect()
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use serde_json::json;
184
185    #[test]
186    fn rate_limit_status_parses_known_values() {
187        assert_eq!(
188            parse_rate_limit_status(Some(&json!("allowed_warning"))),
189            Some(RateLimitStatus::AllowedWarning),
190        );
191        assert_eq!(
192            parse_rate_limit_status(Some(&json!("rejected"))),
193            Some(RateLimitStatus::Rejected)
194        );
195        // Unknown wire string degrades to Unknown via serde(other).
196        assert_eq!(parse_rate_limit_status(Some(&json!("nope"))), Some(RateLimitStatus::Unknown));
197    }
198
199    #[test]
200    fn api_retry_error_falls_back_to_unknown() {
201        assert!(matches!(parse_api_retry_error(None), ApiRetryError::Unknown));
202        assert!(matches!(
203            parse_api_retry_error(Some(&json!("rate_limit"))),
204            ApiRetryError::RateLimit
205        ));
206    }
207
208    #[test]
209    fn rate_limit_update_requires_status() {
210        let v = json!({"resetsAt": 100.0});
211        assert!(build_rate_limit_update(Some(&v)).is_none());
212
213        let v = json!({"status": "allowed", "resetsAt": 100.0, "utilization": 0.5});
214        let u = build_rate_limit_update(Some(&v)).expect("update built");
215        assert_eq!(u.status, RateLimitStatus::Allowed);
216        assert_eq!(u.resets_at, Some(100.0));
217        assert_eq!(u.utilization, Some(0.5));
218    }
219
220    #[test]
221    fn api_retry_update_requires_three_numeric_fields() {
222        let map: Map<String, Value> =
223            serde_json::from_value(json!({"attempt": 1.0, "max_retries": 5.0})).unwrap();
224        assert!(build_api_retry_update(&map).is_none());
225
226        let map: Map<String, Value> = serde_json::from_value(json!({
227            "attempt": 1.0,
228            "max_retries": 5.0,
229            "retry_delay_ms": 1000.0,
230            "error": "rate_limit",
231            "error_status": 429.0,
232        }))
233        .unwrap();
234        let u = build_api_retry_update(&map).expect("update built");
235        assert_eq!(u.attempt, 1);
236        assert_eq!(u.max_retries, 5);
237        assert_eq!(u.retry_delay_ms, 1000);
238        assert_eq!(u.error_status, Some(429));
239        assert!(matches!(u.error, ApiRetryError::RateLimit));
240    }
241
242    #[test]
243    fn api_retry_update_drops_out_of_range_status_and_huge_counts() {
244        // u16 status above max - drop to None.
245        let map: Map<String, Value> = serde_json::from_value(json!({
246            "attempt": 1.0,
247            "max_retries": 5.0,
248            "retry_delay_ms": 1000.0,
249            "error_status": 70_000.0,
250        }))
251        .unwrap();
252        let u = build_api_retry_update(&map).expect("update built");
253        assert_eq!(u.error_status, None);
254
255        // Negative status - drop to None.
256        let map: Map<String, Value> = serde_json::from_value(json!({
257            "attempt": 1.0,
258            "max_retries": 5.0,
259            "retry_delay_ms": 1000.0,
260            "error_status": -1.0,
261        }))
262        .unwrap();
263        let u = build_api_retry_update(&map).expect("update built");
264        assert_eq!(u.error_status, None);
265
266        // u64 count above max - required field, whole update drops.
267        let map: Map<String, Value> = serde_json::from_value(json!({
268            "attempt": 1e30,
269            "max_retries": 5.0,
270            "retry_delay_ms": 1000.0,
271        }))
272        .unwrap();
273        assert!(build_api_retry_update(&map).is_none());
274
275        // Negative required count - whole update drops.
276        let map: Map<String, Value> = serde_json::from_value(json!({
277            "attempt": -1.0,
278            "max_retries": 5.0,
279            "retry_delay_ms": 1000.0,
280        }))
281        .unwrap();
282        assert!(build_api_retry_update(&map).is_none());
283
284        // Boundary case: `u64::MAX as f64` rounds to exactly 2^64
285        // (one past representable). The `>=` upper-bound rejects it;
286        // a `>` would let it through and silent-saturate to u64::MAX.
287        #[allow(clippy::cast_precision_loss)]
288        let boundary = u64::MAX as f64;
289        let map: Map<String, Value> = serde_json::from_value(json!({
290            "attempt": boundary,
291            "max_retries": 5.0,
292            "retry_delay_ms": 1000.0,
293        }))
294        .unwrap();
295        assert!(build_api_retry_update(&map).is_none());
296    }
297
298    #[test]
299    fn settings_parse_error_drops_empty_message() {
300        assert!(normalize_settings_parse_error(&json!({"message": "  "})).is_none());
301        let entry = normalize_settings_parse_error(&json!({
302            "message": "broken json",
303            "path": "/etc/x.json",
304            "file": "x.json",
305        }))
306        .expect("entry");
307        assert_eq!(entry.message, "broken json");
308        assert_eq!(entry.path, "/etc/x.json");
309        assert_eq!(entry.file.as_deref(), Some("x.json"));
310    }
311}