Skip to main content

agent_first_http/sdk/fetch/
result.rs

1//! Fetch result envelope (the JSON the agent reads on success).
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::shared::artifacts::Artifact;
8use crate::shared::error::{Error, ErrorCode};
9use crate::shared::ids::{RequestId, TabId};
10
11/// Result of a successful fetch. Serialized verbatim into the response
12/// envelope; the protocol writer adds the outer `code: "fetch"` tag.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FetchResult {
15    pub request_id: RequestId,
16    pub request_url: String,
17    pub final_url: String,
18    pub status: u16,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub page_kind: Option<PageKind>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub next_action: Option<NextAction>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub tab_id: Option<TabId>,
25    pub trace: Trace,
26    #[serde(skip_serializing_if = "Vec::is_empty", default)]
27    pub warnings: Vec<Warning>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub body_file: Option<PathBuf>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub rendered_html_file: Option<PathBuf>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub text_file: Option<PathBuf>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub content_file: Option<PathBuf>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub content_json_file: Option<PathBuf>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub screenshot_file: Option<PathBuf>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub network_file: Option<PathBuf>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub console_file: Option<PathBuf>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub observation_file: Option<PathBuf>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub storage_file: Option<PathBuf>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub download_file: Option<PathBuf>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub download_bytes: Option<u64>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub download_filename: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub download_url: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub download_state: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61pub struct Trace {
62    pub render_decision: RenderDecision,
63    /// The `--render` mode the agent requested. Distinct from
64    /// `render_decision`: a fetch with `render_mode: "auto"` that escalates
65    /// to the browser reports `render_decision: "browser"` here. Agents
66    /// branching on retry logic can match on this to know whether they
67    /// asked for the browser explicitly or auto-mode chose it.
68    #[serde(default)]
69    pub render_mode: TraceRenderMode,
70    /// `true` when the browser actually ran (i.e. `render_decision ==
71    /// Browser`). Convenience boolean so agents don't have to compare
72    /// enum strings; redundant with `render_decision`, intentionally so.
73    #[serde(default)]
74    pub render_used: bool,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub escalation_reason: Option<String>,
77    pub main_request_observed: bool,
78    pub duration_ms: u64,
79    pub timeout_ms: u64,
80    pub current_stage: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub navigation_duration_ms: Option<u64>,
83    /// Browser wait mode requested for this fetch (`auto`, `load`, `idle`,
84    /// `selector`, `selector_visible`, or `ms`). HTTP-only results omit it.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub wait_mode: Option<String>,
87    /// Mechanical condition that allowed artifact capture to proceed.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub wait_satisfied_by: Option<String>,
90    /// Whether the fetch's own Network collector observed a quiet page at
91    /// capture time. Browser-path only; `None` for HTTP-only or explicit waits
92    /// that do not inspect network quietness.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub network_quiet: Option<bool>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub dom_stable: Option<bool>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub text_stable: Option<bool>,
99    /// Why capture proceeded (`wait_satisfied`, `readiness_timeout`, etc.).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub capture_reason: Option<String>,
102    /// Absolute path of the cookie jar used for this fetch, if any. `None`
103    /// when `--no-cookie-jar` was set or the jar could not be resolved.
104    /// Exposes the implicit "GET /profile → default jar" behaviour that was
105    /// previously invisible to the agent.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub cookie_jar_file: Option<std::path::PathBuf>,
108    /// Structured trace note when the cookie jar could not be resolved from
109    /// `/profile` and the fetch continued without implicit profile cookies.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub cookie_jar_warning: Option<String>,
112    /// Capture knobs that can expose secrets or PII in artifacts. Empty when
113    /// the default redaction posture is in effect.
114    #[serde(skip_serializing_if = "Vec::is_empty", default)]
115    pub sensitive_capture: Vec<String>,
116    pub stages: Vec<TraceStage>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct TraceStage {
121    pub name: String,
122    pub status: TraceStageStatus,
123    pub duration_ms: u64,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum TraceStageStatus {
129    Ok,
130    Error,
131    Timeout,
132    Started,
133}
134
135/// Wire-stable serialization of the `--render` mode for `Trace.render_mode`.
136/// Kept separate from `pipeline::RenderMode` so the SDK exposes the trace
137/// shape without callers having to depend on the pipeline module.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
139#[serde(rename_all = "snake_case")]
140pub enum TraceRenderMode {
141    None,
142    #[default]
143    Auto,
144    Always,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
148#[serde(rename_all = "snake_case")]
149pub enum RenderDecision {
150    #[default]
151    HttpOnly,
152    Browser,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Warning {
157    pub artifact: Artifact,
158    pub code: crate::shared::error::ErrorCode,
159    pub detail: String,
160}
161
162/// Machine-readable classification for pages that are mechanically loaded but
163/// not trustworthy target content (for example Cloudflare/Turnstile walls).
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum PageKind {
167    BotWallDetected,
168    SecurityChallengeDetected,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct NextAction {
173    pub kind: NextActionKind,
174    pub recommended_command: String,
175    pub target_content_verified: bool,
176    /// Real-display takeover URL a human opens to clear the wall. Populated by
177    /// `afhttp fetch --takeover` once it has prepared a persistent tab on a
178    /// takeover-ready host; `None` for a bare fetch that only detected the wall.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub takeover_url: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub takeover_url_expires_at_rfc3339: Option<String>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub takeover_url_ttl_s: Option<u64>,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub takeover_url_scope: Option<String>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum NextActionKind {
192    HumanTakeover,
193}
194
195impl NextAction {
196    fn human_takeover(url: &str) -> Self {
197        Self {
198            kind: NextActionKind::HumanTakeover,
199            recommended_command: format!("afhttp fetch {} --takeover", shell_quote(url)),
200            target_content_verified: false,
201            takeover_url: None,
202            takeover_url_expires_at_rfc3339: None,
203            takeover_url_ttl_s: None,
204            takeover_url_scope: None,
205        }
206    }
207}
208
209/// Fetch-only failure envelope data. This keeps the global `Error` contract
210/// unchanged while allowing `afhttp fetch` to include the in-progress trace.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct FetchError {
213    pub error_code: ErrorCode,
214    #[serde(rename = "error")]
215    pub detail: String,
216    pub retryable: bool,
217    pub trace: Trace,
218}
219
220impl FetchError {
221    #[must_use]
222    pub fn new(error: Error, trace: Trace) -> Self {
223        Self {
224            error_code: error.error_code,
225            detail: error.detail,
226            retryable: error.retryable,
227            trace,
228        }
229    }
230
231    #[must_use]
232    pub fn into_error(self) -> Error {
233        Error {
234            error_code: self.error_code,
235            detail: self.detail,
236            retryable: self.retryable,
237        }
238    }
239
240    #[must_use]
241    pub fn as_error(&self) -> Error {
242        Error {
243            error_code: self.error_code,
244            detail: self.detail.clone(),
245            retryable: self.retryable,
246        }
247    }
248}
249
250/// Canonical `escalation_reason` strings emitted in `Trace.escalation_reason`.
251/// The wire format is `Option<String>`; these constants and constructors are
252/// the single source of values so agents can match without string-parsing
253/// surprise.
254///
255/// | Value | Meaning |
256/// |---|---|
257/// | `"empty_html_shell"` | HTTP returned HTML with no visible text — SPA bootstrap |
258/// | `"http_status_NNN"` | HTTP returned status code NNN |
259/// | `"http_failed_<code>"` | Transport-level failure, `<code>` is the ErrorCode |
260pub struct EscalationReason;
261
262impl EscalationReason {
263    /// HTTP response was an empty SPA shell.
264    pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
265
266    /// HTTP returned status ≥ 400. Produces `"http_status_NNN"`.
267    #[must_use]
268    pub fn http_status(status: u16) -> String {
269        format!("http_status_{status}")
270    }
271
272    /// HTTP transport error. Produces `"http_failed_<error_code>"`.
273    #[must_use]
274    pub fn http_failed(error_code: &str) -> String {
275        format!("http_failed_{error_code}")
276    }
277}
278
279impl FetchResult {
280    /// Base result for `url` carrying `trace`. `final_url` defaults to `url`,
281    /// `status` to 0, and every artifact/download field to empty; the pipeline
282    /// fills those in as captures complete. Avoids repeating the ~15-field
283    /// `None` initializer at each pipeline exit (HTTP, browser, download).
284    pub(crate) fn new(request_id: RequestId, url: String, trace: Trace) -> Self {
285        Self {
286            request_id,
287            final_url: url.clone(),
288            request_url: url,
289            status: 0,
290            page_kind: None,
291            next_action: None,
292            tab_id: None,
293            trace,
294            warnings: Vec::new(),
295            body_file: None,
296            rendered_html_file: None,
297            text_file: None,
298            content_file: None,
299            content_json_file: None,
300            screenshot_file: None,
301            network_file: None,
302            console_file: None,
303            observation_file: None,
304            storage_file: None,
305            download_file: None,
306            download_bytes: None,
307            download_filename: None,
308            download_url: None,
309            download_state: None,
310        }
311    }
312
313    /// Convenience for setting an artifact path in its top-level `*_file`
314    /// field. The JSON contract intentionally does not nest these paths.
315    pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
316        match artifact {
317            Artifact::Body => self.body_file = Some(path),
318            Artifact::RenderedHtml => self.rendered_html_file = Some(path),
319            Artifact::Text => self.text_file = Some(path),
320            Artifact::Content => self.content_file = Some(path),
321            Artifact::ContentJson => self.content_json_file = Some(path),
322            Artifact::Screenshot => self.screenshot_file = Some(path),
323            Artifact::Network => self.network_file = Some(path),
324            Artifact::Console => self.console_file = Some(path),
325            Artifact::Observation => self.observation_file = Some(path),
326            Artifact::Storage => self.storage_file = Some(path),
327        }
328    }
329
330    pub(crate) fn set_page_kind(&mut self, kind: PageKind) {
331        self.page_kind = Some(kind);
332        if matches!(
333            kind,
334            PageKind::BotWallDetected | PageKind::SecurityChallengeDetected
335        ) {
336            self.next_action = Some(NextAction::human_takeover(&self.request_url));
337        }
338    }
339
340    /// Enrich a detected wall's `next_action` for `afhttp fetch --takeover`:
341    /// attach the human takeover URL and recommend re-fetching the same
342    /// persistent tab once the human clears the wall. No-op when no wall was
343    /// detected (no `next_action`).
344    pub fn attach_takeover(&mut self, takeover_url: String) {
345        self.attach_takeover_with_context(takeover_url, None, None, None, None, None);
346    }
347
348    /// Like [`Self::attach_takeover`], but also makes the recommended command
349    /// self-contained for non-default hosts and explicitly shared profiles.
350    pub fn attach_takeover_with_context(
351        &mut self,
352        takeover_url: String,
353        expires_at_rfc3339: Option<String>,
354        ttl_s: Option<u64>,
355        scope: Option<String>,
356        endpoint: Option<&str>,
357        profile: Option<&str>,
358    ) {
359        let url = self.request_url.clone();
360        let tab = self.tab_id.as_ref().map(|t| t.as_str().to_string());
361        if let Some(next) = self.next_action.as_mut() {
362            next.takeover_url = Some(takeover_url);
363            next.takeover_url_expires_at_rfc3339 = expires_at_rfc3339;
364            next.takeover_url_ttl_s = ttl_s;
365            next.takeover_url_scope = scope;
366            if let Some(tab) = tab {
367                let mut command = format!(
368                    "afhttp fetch {} --takeover --tab {}",
369                    shell_quote(&url),
370                    shell_quote(&tab),
371                );
372                if let Some(endpoint) = endpoint {
373                    command.push_str(" --endpoint-url ");
374                    command.push_str(&shell_quote(endpoint));
375                }
376                if let Some(profile) = profile {
377                    command.push_str(" --profile ");
378                    command.push_str(&shell_quote(profile));
379                }
380                next.recommended_command = command;
381            }
382        }
383    }
384
385    #[must_use]
386    pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
387        match artifact {
388            Artifact::Body => self.body_file.as_ref(),
389            Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
390            Artifact::Text => self.text_file.as_ref(),
391            Artifact::Content => self.content_file.as_ref(),
392            Artifact::ContentJson => self.content_json_file.as_ref(),
393            Artifact::Screenshot => self.screenshot_file.as_ref(),
394            Artifact::Network => self.network_file.as_ref(),
395            Artifact::Console => self.console_file.as_ref(),
396            Artifact::Observation => self.observation_file.as_ref(),
397            Artifact::Storage => self.storage_file.as_ref(),
398        }
399    }
400}
401
402fn shell_quote(value: &str) -> String {
403    if !value.is_empty()
404        && value.bytes().all(|b| {
405            b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b':' | b',' | b'=')
406        })
407    {
408        return value.to_string();
409    }
410    format!("'{}'", value.replace('\'', "'\\''"))
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn fetch_success_json_is_flat_golden() {
419        let mut result = FetchResult::new(
420            RequestId("req-1".into()),
421            "https://example.com/".into(),
422            Trace {
423                render_decision: RenderDecision::Browser,
424                render_mode: TraceRenderMode::Always,
425                render_used: true,
426                escalation_reason: None,
427                main_request_observed: true,
428                duration_ms: 12,
429                timeout_ms: 30000,
430                current_stage: "complete".into(),
431                navigation_duration_ms: Some(8),
432                wait_mode: Some("load".into()),
433                wait_satisfied_by: Some("load".into()),
434                network_quiet: None,
435                dom_stable: None,
436                text_stable: None,
437                capture_reason: Some("wait_satisfied".into()),
438                cookie_jar_file: None,
439                cookie_jar_warning: None,
440                sensitive_capture: Vec::new(),
441                stages: vec![
442                    TraceStage {
443                        name: "navigate".into(),
444                        status: TraceStageStatus::Ok,
445                        duration_ms: 8,
446                    },
447                    TraceStage {
448                        name: "capture_text".into(),
449                        status: TraceStageStatus::Ok,
450                        duration_ms: 4,
451                    },
452                ],
453            },
454        );
455        result.status = 200;
456        for (artifact, file) in [
457            (Artifact::Body, "body.html"),
458            (Artifact::RenderedHtml, "rendered.html"),
459            (Artifact::Text, "text.txt"),
460            (Artifact::Screenshot, "page.png"),
461            (Artifact::Network, "network.json"),
462            (Artifact::Console, "console.json"),
463            (Artifact::Observation, "observation.json"),
464            (Artifact::Storage, "storage.json"),
465        ] {
466            result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
467        }
468        let mut buf = Vec::new();
469        crate::shared::afdata::emit(&mut buf, "fetch", &result).unwrap();
470        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
471        agent_first_data::validate_protocol_event(&json, true).unwrap();
472        assert!(
473            json["result"].get("artifacts").is_none(),
474            "artifacts map must be gone"
475        );
476        let canonical = serde_json::to_string(&json).unwrap();
477        let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
478        assert_eq!(canonical, expected);
479    }
480
481    #[test]
482    fn bot_wall_page_kind_adds_human_takeover_next_action() {
483        let mut result = FetchResult::new(
484            RequestId("req-1".into()),
485            "https://example.com/a b".into(),
486            Trace::default(),
487        );
488        result.set_page_kind(PageKind::BotWallDetected);
489        let next = result.next_action.expect("next_action");
490        assert_eq!(next.kind, NextActionKind::HumanTakeover);
491        assert!(!next.target_content_verified);
492        assert_eq!(
493            next.recommended_command,
494            "afhttp fetch 'https://example.com/a b' --takeover"
495        );
496    }
497
498    #[test]
499    fn takeover_next_action_can_include_connection_and_profile_context() {
500        let mut result = FetchResult::new(
501            RequestId("req-1".into()),
502            "https://example.com/login".into(),
503            Trace::default(),
504        );
505        result.tab_id = Some(TabId::new("page 7"));
506        result.set_page_kind(PageKind::BotWallDetected);
507        result.attach_takeover_with_context(
508            "http://127.0.0.1:9222/takeover/panel?handoff=h".into(),
509            Some("2026-06-11T00:00:00Z".into()),
510            Some(900),
511            Some("takeover".into()),
512            Some("ws://127.0.0.1:9222"),
513            Some("work profile"),
514        );
515
516        let next = result.next_action.expect("next_action");
517        assert_eq!(
518            next.recommended_command,
519            "afhttp fetch https://example.com/login --takeover --tab 'page 7' --endpoint-url ws://127.0.0.1:9222 --profile 'work profile'"
520        );
521        assert_eq!(
522            next.takeover_url.as_deref(),
523            Some("http://127.0.0.1:9222/takeover/panel?handoff=h")
524        );
525        assert_eq!(next.takeover_url_ttl_s, Some(900));
526        assert_eq!(next.takeover_url_scope.as_deref(), Some("takeover"));
527    }
528}