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