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    #[serde(alias = "takeover_url")]
181    pub takeover_url_secret: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub takeover_url_expires_at_rfc3339: Option<String>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub takeover_url_ttl_s: Option<u64>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub takeover_url_scope: Option<String>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum NextActionKind {
193    HumanTakeover,
194}
195
196impl NextAction {
197    fn human_takeover(url: &str) -> Self {
198        Self {
199            kind: NextActionKind::HumanTakeover,
200            recommended_command: format!("afhttp fetch {} --takeover", shell_quote(url)),
201            target_content_verified: false,
202            takeover_url_secret: None,
203            takeover_url_expires_at_rfc3339: None,
204            takeover_url_ttl_s: None,
205            takeover_url_scope: None,
206        }
207    }
208}
209
210/// Fetch-only failure envelope data. This keeps the global `Error` contract
211/// unchanged while allowing `afhttp fetch` to include the in-progress trace.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct FetchError {
214    pub error_code: ErrorCode,
215    #[serde(rename = "error")]
216    pub detail: String,
217    pub retryable: bool,
218    pub trace: Trace,
219}
220
221impl FetchError {
222    #[must_use]
223    pub fn new(error: Error, trace: Trace) -> Self {
224        Self {
225            error_code: error.error_code,
226            detail: error.detail,
227            retryable: error.retryable,
228            trace,
229        }
230    }
231
232    #[must_use]
233    pub fn into_error(self) -> Error {
234        Error {
235            error_code: self.error_code,
236            detail: self.detail,
237            retryable: self.retryable,
238        }
239    }
240
241    #[must_use]
242    pub fn as_error(&self) -> Error {
243        Error {
244            error_code: self.error_code,
245            detail: self.detail.clone(),
246            retryable: self.retryable,
247        }
248    }
249}
250
251/// Canonical `escalation_reason` strings emitted in `Trace.escalation_reason`.
252/// The wire format is `Option<String>`; these constants and constructors are
253/// the single source of values so agents can match without string-parsing
254/// surprise.
255///
256/// | Value | Meaning |
257/// |---|---|
258/// | `"empty_html_shell"` | HTTP returned HTML with no visible text — SPA bootstrap |
259/// | `"http_status_NNN"` | HTTP returned status code NNN |
260/// | `"http_failed_<code>"` | Transport-level failure, `<code>` is the ErrorCode |
261pub struct EscalationReason;
262
263impl EscalationReason {
264    /// HTTP response was an empty SPA shell.
265    pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
266
267    /// HTTP returned status ≥ 400. Produces `"http_status_NNN"`.
268    #[must_use]
269    pub fn http_status(status: u16) -> String {
270        format!("http_status_{status}")
271    }
272
273    /// HTTP transport error. Produces `"http_failed_<error_code>"`.
274    #[must_use]
275    pub fn http_failed(error_code: &str) -> String {
276        format!("http_failed_{error_code}")
277    }
278}
279
280impl FetchResult {
281    /// Base result for `url` carrying `trace`. `final_url` defaults to `url`,
282    /// `status` to 0, and every artifact/download field to empty; the pipeline
283    /// fills those in as captures complete. Avoids repeating the ~15-field
284    /// `None` initializer at each pipeline exit (HTTP, browser, download).
285    pub(crate) fn new(request_id: RequestId, url: String, trace: Trace) -> Self {
286        Self {
287            request_id,
288            final_url: url.clone(),
289            request_url: url,
290            status: 0,
291            page_kind: None,
292            next_action: None,
293            tab_id: None,
294            trace,
295            warnings: Vec::new(),
296            body_file: None,
297            rendered_html_file: None,
298            text_file: None,
299            content_file: None,
300            content_json_file: None,
301            screenshot_file: None,
302            network_file: None,
303            console_file: None,
304            observation_file: None,
305            storage_file: None,
306            download_file: None,
307            download_bytes: None,
308            download_filename: None,
309            download_url: None,
310            download_state: None,
311        }
312    }
313
314    /// Convenience for setting an artifact path in its top-level `*_file`
315    /// field. The JSON contract intentionally does not nest these paths.
316    pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
317        match artifact {
318            Artifact::Body => self.body_file = Some(path),
319            Artifact::RenderedHtml => self.rendered_html_file = Some(path),
320            Artifact::Text => self.text_file = Some(path),
321            Artifact::Content => self.content_file = Some(path),
322            Artifact::ContentJson => self.content_json_file = Some(path),
323            Artifact::Screenshot => self.screenshot_file = Some(path),
324            Artifact::Network => self.network_file = Some(path),
325            Artifact::Console => self.console_file = Some(path),
326            Artifact::Observation => self.observation_file = Some(path),
327            Artifact::Storage => self.storage_file = Some(path),
328        }
329    }
330
331    pub(crate) fn set_page_kind(&mut self, kind: PageKind) {
332        self.page_kind = Some(kind);
333        if matches!(
334            kind,
335            PageKind::BotWallDetected | PageKind::SecurityChallengeDetected
336        ) {
337            self.next_action = Some(NextAction::human_takeover(&self.request_url));
338        }
339    }
340
341    /// Enrich a detected wall's `next_action` for `afhttp fetch --takeover`:
342    /// attach the human takeover URL and recommend re-fetching the same
343    /// persistent tab once the human clears the wall. No-op when no wall was
344    /// detected (no `next_action`).
345    pub fn attach_takeover(&mut self, takeover_url_secret: String) {
346        self.attach_takeover_with_context(takeover_url_secret, None, None, None, None, None);
347    }
348
349    /// Like [`Self::attach_takeover`], but also makes the recommended command
350    /// self-contained for non-default hosts and explicitly shared profiles.
351    pub fn attach_takeover_with_context(
352        &mut self,
353        takeover_url_secret: String,
354        expires_at_rfc3339: Option<String>,
355        ttl_s: Option<u64>,
356        scope: Option<String>,
357        endpoint: Option<&str>,
358        profile: Option<&str>,
359    ) {
360        let url = self.request_url.clone();
361        let tab = self.tab_id.as_ref().map(|t| t.as_str().to_string());
362        if let Some(next) = self.next_action.as_mut() {
363            next.takeover_url_secret = Some(takeover_url_secret);
364            next.takeover_url_expires_at_rfc3339 = expires_at_rfc3339;
365            next.takeover_url_ttl_s = ttl_s;
366            next.takeover_url_scope = scope;
367            if let Some(tab) = tab {
368                let mut command = format!(
369                    "afhttp fetch {} --takeover --tab {}",
370                    shell_quote(&url),
371                    shell_quote(&tab),
372                );
373                if let Some(endpoint) = endpoint {
374                    command.push_str(" --endpoint-url ");
375                    command.push_str(&shell_quote(endpoint));
376                }
377                if let Some(profile) = profile {
378                    command.push_str(" --profile ");
379                    command.push_str(&shell_quote(profile));
380                }
381                next.recommended_command = command;
382            }
383        }
384    }
385
386    #[must_use]
387    pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
388        match artifact {
389            Artifact::Body => self.body_file.as_ref(),
390            Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
391            Artifact::Text => self.text_file.as_ref(),
392            Artifact::Content => self.content_file.as_ref(),
393            Artifact::ContentJson => self.content_json_file.as_ref(),
394            Artifact::Screenshot => self.screenshot_file.as_ref(),
395            Artifact::Network => self.network_file.as_ref(),
396            Artifact::Console => self.console_file.as_ref(),
397            Artifact::Observation => self.observation_file.as_ref(),
398            Artifact::Storage => self.storage_file.as_ref(),
399        }
400    }
401}
402
403fn shell_quote(value: &str) -> String {
404    if !value.is_empty()
405        && value.bytes().all(|b| {
406            b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b':' | b',' | b'=')
407        })
408    {
409        return value.to_string();
410    }
411    format!("'{}'", value.replace('\'', "'\\''"))
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn fetch_success_json_is_flat_golden() {
420        let mut result = FetchResult::new(
421            RequestId("req-1".into()),
422            "https://example.com/".into(),
423            Trace {
424                render_decision: RenderDecision::Browser,
425                render_mode: TraceRenderMode::Always,
426                render_used: true,
427                escalation_reason: None,
428                main_request_observed: true,
429                duration_ms: 12,
430                timeout_ms: 30000,
431                current_stage: "complete".into(),
432                navigation_duration_ms: Some(8),
433                wait_mode: Some("load".into()),
434                wait_satisfied_by: Some("load".into()),
435                network_quiet: None,
436                dom_stable: None,
437                text_stable: None,
438                capture_reason: Some("wait_satisfied".into()),
439                cookie_jar_file: None,
440                cookie_jar_warning: None,
441                sensitive_capture: Vec::new(),
442                stages: vec![
443                    TraceStage {
444                        name: "navigate".into(),
445                        status: TraceStageStatus::Ok,
446                        duration_ms: 8,
447                    },
448                    TraceStage {
449                        name: "capture_text".into(),
450                        status: TraceStageStatus::Ok,
451                        duration_ms: 4,
452                    },
453                ],
454            },
455        );
456        result.status = 200;
457        for (artifact, file) in [
458            (Artifact::Body, "body.html"),
459            (Artifact::RenderedHtml, "rendered.html"),
460            (Artifact::Text, "text.txt"),
461            (Artifact::Screenshot, "page.png"),
462            (Artifact::Network, "network.json"),
463            (Artifact::Console, "console.json"),
464            (Artifact::Observation, "observation.json"),
465            (Artifact::Storage, "storage.json"),
466        ] {
467            result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
468        }
469        let mut buf = Vec::new();
470        crate::shared::afdata::emit(&mut buf, "fetch", &result).unwrap();
471        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
472        agent_first_data::validate_protocol_event(&json, true).unwrap();
473        assert!(
474            json["result"].get("artifacts").is_none(),
475            "artifacts map must be gone"
476        );
477        let canonical = serde_json::to_string(&json).unwrap();
478        let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
479        assert_eq!(canonical, expected);
480    }
481
482    #[test]
483    fn bot_wall_page_kind_adds_human_takeover_next_action() {
484        let mut result = FetchResult::new(
485            RequestId("req-1".into()),
486            "https://example.com/a b".into(),
487            Trace::default(),
488        );
489        result.set_page_kind(PageKind::BotWallDetected);
490        let next = result.next_action.expect("next_action");
491        assert_eq!(next.kind, NextActionKind::HumanTakeover);
492        assert!(!next.target_content_verified);
493        assert_eq!(
494            next.recommended_command,
495            "afhttp fetch 'https://example.com/a b' --takeover"
496        );
497    }
498
499    #[test]
500    fn takeover_next_action_can_include_connection_and_profile_context() {
501        let mut result = FetchResult::new(
502            RequestId("req-1".into()),
503            "https://example.com/login".into(),
504            Trace::default(),
505        );
506        result.tab_id = Some(TabId::new("page 7"));
507        result.set_page_kind(PageKind::BotWallDetected);
508        result.attach_takeover_with_context(
509            "http://127.0.0.1:9222/takeover/panel?handoff_secret=h".into(),
510            Some("2026-06-11T00:00:00Z".into()),
511            Some(900),
512            Some("takeover".into()),
513            Some("ws://127.0.0.1:9222"),
514            Some("work profile"),
515        );
516
517        let next = result.next_action.expect("next_action");
518        assert_eq!(
519            next.recommended_command,
520            "afhttp fetch https://example.com/login --takeover --tab 'page 7' --endpoint-url ws://127.0.0.1:9222 --profile 'work profile'"
521        );
522        assert_eq!(
523            next.takeover_url_secret.as_deref(),
524            Some("http://127.0.0.1:9222/takeover/panel?handoff_secret=h")
525        );
526        assert_eq!(next.takeover_url_ttl_s, Some(900));
527        assert_eq!(next.takeover_url_scope.as_deref(), Some("takeover"));
528    }
529}