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 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 tab_id: Option<TabId>,
23    pub trace: Trace,
24    #[serde(skip_serializing_if = "Vec::is_empty", default)]
25    pub warnings: Vec<Warning>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub body_file: Option<PathBuf>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub rendered_html_file: Option<PathBuf>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub text_file: Option<PathBuf>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub screenshot_file: Option<PathBuf>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub network_file: Option<PathBuf>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub console_file: Option<PathBuf>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub observation_file: Option<PathBuf>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub storage_file: Option<PathBuf>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub download_file: Option<PathBuf>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub download_bytes: Option<u64>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub download_filename: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub download_url: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub download_state: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct Trace {
56    pub render_decision: RenderDecision,
57    /// The `--render` mode the agent requested. Distinct from
58    /// `render_decision`: a fetch with `render_mode: "auto"` that escalates
59    /// to the browser reports `render_decision: "browser"` here. Agents
60    /// branching on retry logic can match on this to know whether they
61    /// asked for the browser explicitly or auto-mode chose it.
62    #[serde(default)]
63    pub render_mode: TraceRenderMode,
64    /// `true` when the browser actually ran (i.e. `render_decision ==
65    /// Browser`). Convenience boolean so agents don't have to compare
66    /// enum strings; redundant with `render_decision`, intentionally so.
67    #[serde(default)]
68    pub render_used: bool,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub escalation_reason: Option<String>,
71    pub main_request_observed: bool,
72    pub duration_ms: u64,
73    pub timeout_ms: u64,
74    pub current_stage: String,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub navigation_duration_ms: Option<u64>,
77    /// Browser wait mode requested for this fetch (`auto`, `load`, `idle`,
78    /// `selector`, `selector_visible`, or `ms`). HTTP-only results omit it.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub wait_mode: Option<String>,
81    /// Mechanical condition that allowed artifact capture to proceed.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub wait_satisfied_by: Option<String>,
84    /// Whether the fetch's own Network collector observed a quiet page at
85    /// capture time. Browser-path only; `None` for HTTP-only or explicit waits
86    /// that do not inspect network quietness.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub network_quiet: Option<bool>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub dom_stable: Option<bool>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub text_stable: Option<bool>,
93    /// Why capture proceeded (`wait_satisfied`, `readiness_timeout`, etc.).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub capture_reason: Option<String>,
96    /// Absolute path of the cookie jar used for this fetch, if any. `None`
97    /// when `--no-cookie-jar` was set or the jar could not be resolved.
98    /// Exposes the implicit "GET /profile → default jar" behaviour that was
99    /// previously invisible to the agent.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub cookie_jar_file: Option<std::path::PathBuf>,
102    /// Structured trace note when the cookie jar could not be resolved from
103    /// `/profile` and the fetch continued without implicit profile cookies.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub cookie_jar_warning: Option<String>,
106    /// Capture knobs that can expose secrets or PII in artifacts. Empty when
107    /// the default redaction posture is in effect.
108    #[serde(skip_serializing_if = "Vec::is_empty", default)]
109    pub sensitive_capture: Vec<String>,
110    pub stages: Vec<TraceStage>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct TraceStage {
115    pub name: String,
116    pub status: TraceStageStatus,
117    pub duration_ms: u64,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum TraceStageStatus {
123    Ok,
124    Error,
125    Timeout,
126    Started,
127}
128
129/// Wire-stable serialization of the `--render` mode for `Trace.render_mode`.
130/// Kept separate from `pipeline::RenderMode` so the SDK exposes the trace
131/// shape without callers having to depend on the pipeline module.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
133#[serde(rename_all = "snake_case")]
134pub enum TraceRenderMode {
135    None,
136    #[default]
137    Auto,
138    Always,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
142#[serde(rename_all = "snake_case")]
143pub enum RenderDecision {
144    #[default]
145    HttpOnly,
146    Browser,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Warning {
151    pub artifact: Artifact,
152    pub code: crate::shared::error::ErrorCode,
153    pub detail: String,
154}
155
156/// Machine-readable classification for pages that are mechanically loaded but
157/// not trustworthy target content (for example Cloudflare/Turnstile walls).
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum PageKind {
161    BotWallDetected,
162    SecurityChallengeDetected,
163}
164
165/// Fetch-only failure envelope data. This keeps the global `Error` contract
166/// unchanged while allowing `afhttp fetch` to include the in-progress trace.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct FetchError {
169    pub error_code: ErrorCode,
170    #[serde(rename = "error")]
171    pub detail: String,
172    pub retryable: bool,
173    pub trace: Trace,
174}
175
176impl FetchError {
177    #[must_use]
178    pub fn new(error: Error, trace: Trace) -> Self {
179        Self {
180            error_code: error.error_code,
181            detail: error.detail,
182            retryable: error.retryable,
183            trace,
184        }
185    }
186
187    #[must_use]
188    pub fn into_error(self) -> Error {
189        Error {
190            error_code: self.error_code,
191            detail: self.detail,
192            retryable: self.retryable,
193        }
194    }
195
196    #[must_use]
197    pub fn as_error(&self) -> Error {
198        Error {
199            error_code: self.error_code,
200            detail: self.detail.clone(),
201            retryable: self.retryable,
202        }
203    }
204}
205
206/// Canonical `escalation_reason` strings emitted in `Trace.escalation_reason`.
207/// The wire format is `Option<String>`; these constants and constructors are
208/// the single source of values so agents can match without string-parsing
209/// surprise.
210///
211/// | Value | Meaning |
212/// |---|---|
213/// | `"empty_html_shell"` | HTTP returned HTML with no visible text — SPA bootstrap |
214/// | `"http_status_NNN"` | HTTP returned status code NNN |
215/// | `"http_failed_<code>"` | Transport-level failure, `<code>` is the ErrorCode |
216pub struct EscalationReason;
217
218impl EscalationReason {
219    /// HTTP response was an empty SPA shell.
220    pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
221
222    /// HTTP returned status ≥ 400. Produces `"http_status_NNN"`.
223    #[must_use]
224    pub fn http_status(status: u16) -> String {
225        format!("http_status_{status}")
226    }
227
228    /// HTTP transport error. Produces `"http_failed_<error_code>"`.
229    #[must_use]
230    pub fn http_failed(error_code: &str) -> String {
231        format!("http_failed_{error_code}")
232    }
233}
234
235impl FetchResult {
236    /// Base result for `url` carrying `trace`. `final_url` defaults to `url`,
237    /// `status` to 0, and every artifact/download field to empty; the pipeline
238    /// fills those in as captures complete. Avoids repeating the ~15-field
239    /// `None` initializer at each pipeline exit (HTTP, browser, download).
240    pub(crate) fn new(request_id: RequestId, url: String, trace: Trace) -> Self {
241        Self {
242            request_id,
243            final_url: url.clone(),
244            url,
245            status: 0,
246            page_kind: None,
247            tab_id: None,
248            trace,
249            warnings: Vec::new(),
250            body_file: None,
251            rendered_html_file: None,
252            text_file: None,
253            screenshot_file: None,
254            network_file: None,
255            console_file: None,
256            observation_file: None,
257            storage_file: None,
258            download_file: None,
259            download_bytes: None,
260            download_filename: None,
261            download_url: None,
262            download_state: None,
263        }
264    }
265
266    /// Convenience for setting an artifact path in its top-level `*_file`
267    /// field. The JSON contract intentionally does not nest these paths.
268    pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
269        match artifact {
270            Artifact::Body => self.body_file = Some(path),
271            Artifact::RenderedHtml => self.rendered_html_file = Some(path),
272            Artifact::Text => self.text_file = Some(path),
273            Artifact::Screenshot => self.screenshot_file = Some(path),
274            Artifact::Network => self.network_file = Some(path),
275            Artifact::Console => self.console_file = Some(path),
276            Artifact::Observation => self.observation_file = Some(path),
277            Artifact::Storage => self.storage_file = Some(path),
278        }
279    }
280
281    #[must_use]
282    pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
283        match artifact {
284            Artifact::Body => self.body_file.as_ref(),
285            Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
286            Artifact::Text => self.text_file.as_ref(),
287            Artifact::Screenshot => self.screenshot_file.as_ref(),
288            Artifact::Network => self.network_file.as_ref(),
289            Artifact::Console => self.console_file.as_ref(),
290            Artifact::Observation => self.observation_file.as_ref(),
291            Artifact::Storage => self.storage_file.as_ref(),
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn fetch_success_json_is_flat_golden() {
302        let mut result = FetchResult::new(
303            RequestId("req-1".into()),
304            "https://example.com/".into(),
305            Trace {
306                render_decision: RenderDecision::Browser,
307                render_mode: TraceRenderMode::Always,
308                render_used: true,
309                escalation_reason: None,
310                main_request_observed: true,
311                duration_ms: 12,
312                timeout_ms: 30000,
313                current_stage: "complete".into(),
314                navigation_duration_ms: Some(8),
315                wait_mode: Some("load".into()),
316                wait_satisfied_by: Some("load".into()),
317                network_quiet: None,
318                dom_stable: None,
319                text_stable: None,
320                capture_reason: Some("wait_satisfied".into()),
321                cookie_jar_file: None,
322                cookie_jar_warning: None,
323                sensitive_capture: Vec::new(),
324                stages: vec![
325                    TraceStage {
326                        name: "navigate".into(),
327                        status: TraceStageStatus::Ok,
328                        duration_ms: 8,
329                    },
330                    TraceStage {
331                        name: "capture_text".into(),
332                        status: TraceStageStatus::Ok,
333                        duration_ms: 4,
334                    },
335                ],
336            },
337        );
338        result.status = 200;
339        for (artifact, file) in [
340            (Artifact::Body, "body.html"),
341            (Artifact::RenderedHtml, "rendered.html"),
342            (Artifact::Text, "text.txt"),
343            (Artifact::Screenshot, "page.png"),
344            (Artifact::Network, "network.json"),
345            (Artifact::Console, "console.json"),
346            (Artifact::Observation, "observation.json"),
347            (Artifact::Storage, "storage.json"),
348        ] {
349            result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
350        }
351        let mut buf = Vec::new();
352        crate::shared::envelope::emit(&mut buf, "fetch", &result).unwrap();
353        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
354        assert!(
355            json.get("artifacts").is_none(),
356            "artifacts map must be gone"
357        );
358        let canonical = serde_json::to_string(&json).unwrap();
359        let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
360        assert_eq!(canonical, expected);
361    }
362}