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::ids::{RequestId, TabId};
9
10/// Result of a successful fetch. Serialized verbatim into the response
11/// envelope; the protocol writer adds the outer `code: "fetch"` tag.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct FetchResult {
14    pub request_id: RequestId,
15    pub url: String,
16    pub final_url: String,
17    pub status: u16,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub tab_id: Option<TabId>,
20    pub trace: Trace,
21    #[serde(skip_serializing_if = "Vec::is_empty", default)]
22    pub warnings: Vec<Warning>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub body_file: Option<PathBuf>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub rendered_html_file: Option<PathBuf>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub text_file: Option<PathBuf>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub screenshot_file: Option<PathBuf>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub network_file: Option<PathBuf>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub console_file: Option<PathBuf>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub observation_file: Option<PathBuf>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub storage_file: Option<PathBuf>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub download_file: Option<PathBuf>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub download_bytes: Option<u64>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub download_filename: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub download_url: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub download_state: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct Trace {
53    pub render_decision: RenderDecision,
54    /// The `--render` mode the agent requested. Distinct from
55    /// `render_decision`: a fetch with `render_mode: "auto"` that escalates
56    /// to the browser reports `render_decision: "browser"` here. Agents
57    /// branching on retry logic can match on this to know whether they
58    /// asked for the browser explicitly or auto-mode chose it.
59    #[serde(default)]
60    pub render_mode: TraceRenderMode,
61    /// `true` when the browser actually ran (i.e. `render_decision ==
62    /// Browser`). Convenience boolean so agents don't have to compare
63    /// enum strings; redundant with `render_decision`, intentionally so.
64    #[serde(default)]
65    pub render_used: bool,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub escalation_reason: Option<String>,
68    pub main_request_observed: bool,
69    pub duration_ms: u64,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub navigation_duration_ms: Option<u64>,
72    /// Absolute path of the cookie jar used for this fetch, if any. `None`
73    /// when `--no-cookie-jar` was set or the jar could not be resolved.
74    /// Exposes the implicit "GET /profile → default jar" behaviour that was
75    /// previously invisible to the agent.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub cookie_jar_file: Option<std::path::PathBuf>,
78    /// Structured trace note when the cookie jar could not be resolved from
79    /// `/profile` and the fetch continued without implicit profile cookies.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub cookie_jar_warning: Option<String>,
82    /// Capture knobs that can expose secrets or PII in artifacts. Empty when
83    /// the default redaction posture is in effect.
84    #[serde(skip_serializing_if = "Vec::is_empty", default)]
85    pub sensitive_capture: Vec<String>,
86}
87
88/// Wire-stable serialization of the `--render` mode for `Trace.render_mode`.
89/// Kept separate from `pipeline::RenderMode` so the SDK exposes the trace
90/// shape without callers having to depend on the pipeline module.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
92#[serde(rename_all = "snake_case")]
93pub enum TraceRenderMode {
94    None,
95    #[default]
96    Auto,
97    Always,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
101#[serde(rename_all = "snake_case")]
102pub enum RenderDecision {
103    #[default]
104    HttpOnly,
105    Browser,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Warning {
110    pub artifact: Artifact,
111    pub code: crate::shared::error::ErrorCode,
112    pub detail: String,
113}
114
115/// Canonical `escalation_reason` strings emitted in `Trace.escalation_reason`.
116/// The wire format is `Option<String>`; these constants and constructors are
117/// the single source of values so agents can match without string-parsing
118/// surprise.
119///
120/// | Value | Meaning |
121/// |---|---|
122/// | `"empty_html_shell"` | HTTP returned HTML with no visible text — SPA bootstrap |
123/// | `"http_status_NNN"` | HTTP returned status code NNN |
124/// | `"http_failed_<code>"` | Transport-level failure, `<code>` is the ErrorCode |
125pub struct EscalationReason;
126
127impl EscalationReason {
128    /// HTTP response was an empty SPA shell.
129    pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
130
131    /// HTTP returned status ≥ 400. Produces `"http_status_NNN"`.
132    #[must_use]
133    pub fn http_status(status: u16) -> String {
134        format!("http_status_{status}")
135    }
136
137    /// HTTP transport error. Produces `"http_failed_<error_code>"`.
138    #[must_use]
139    pub fn http_failed(error_code: &str) -> String {
140        format!("http_failed_{error_code}")
141    }
142}
143
144impl FetchResult {
145    /// Convenience for setting an artifact path in its top-level `*_file`
146    /// field. The JSON contract intentionally does not nest these paths.
147    pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
148        match artifact {
149            Artifact::Body => self.body_file = Some(path),
150            Artifact::RenderedHtml => self.rendered_html_file = Some(path),
151            Artifact::Text => self.text_file = Some(path),
152            Artifact::Screenshot => self.screenshot_file = Some(path),
153            Artifact::Network => self.network_file = Some(path),
154            Artifact::Console => self.console_file = Some(path),
155            Artifact::Observation => self.observation_file = Some(path),
156            Artifact::Storage => self.storage_file = Some(path),
157        }
158    }
159
160    #[must_use]
161    pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
162        match artifact {
163            Artifact::Body => self.body_file.as_ref(),
164            Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
165            Artifact::Text => self.text_file.as_ref(),
166            Artifact::Screenshot => self.screenshot_file.as_ref(),
167            Artifact::Network => self.network_file.as_ref(),
168            Artifact::Console => self.console_file.as_ref(),
169            Artifact::Observation => self.observation_file.as_ref(),
170            Artifact::Storage => self.storage_file.as_ref(),
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn fetch_success_json_is_flat_golden() {
181        let mut result = FetchResult {
182            request_id: RequestId("req-1".into()),
183            url: "https://example.com/".into(),
184            final_url: "https://example.com/".into(),
185            status: 200,
186            tab_id: None,
187            trace: Trace {
188                render_decision: RenderDecision::Browser,
189                render_mode: TraceRenderMode::Always,
190                render_used: true,
191                escalation_reason: None,
192                main_request_observed: true,
193                duration_ms: 12,
194                navigation_duration_ms: Some(8),
195                cookie_jar_file: None,
196                cookie_jar_warning: None,
197                sensitive_capture: Vec::new(),
198            },
199            warnings: Vec::new(),
200            body_file: None,
201            rendered_html_file: None,
202            text_file: None,
203            screenshot_file: None,
204            network_file: None,
205            console_file: None,
206            observation_file: None,
207            storage_file: None,
208            download_file: None,
209            download_bytes: None,
210            download_filename: None,
211            download_url: None,
212            download_state: None,
213        };
214        for (artifact, file) in [
215            (Artifact::Body, "body.html"),
216            (Artifact::RenderedHtml, "rendered.html"),
217            (Artifact::Text, "text.txt"),
218            (Artifact::Screenshot, "page.png"),
219            (Artifact::Network, "network.json"),
220            (Artifact::Console, "console.json"),
221            (Artifact::Observation, "observation.json"),
222            (Artifact::Storage, "storage.json"),
223        ] {
224            result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
225        }
226        let mut buf = Vec::new();
227        crate::shared::envelope::emit(&mut buf, "fetch", &result).unwrap();
228        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
229        assert!(
230            json.get("artifacts").is_none(),
231            "artifacts map must be gone"
232        );
233        let canonical = serde_json::to_string(&json).unwrap();
234        let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
235        assert_eq!(canonical, expected);
236    }
237}