Skip to main content

axon/
http_tool.rs

1//! HTTP tool provider — executes tool calls as REST requests via reqwest.
2//!
3//! Tools declared with `provider: http` in .axon files dispatch their
4//! argument as the request body to the URL specified in `runtime`.
5//!
6//! Request format:
7//!   POST {runtime_url}
8//!   Content-Type: application/json
9//!   X-Axon-Tool: {tool_name}
10//!
11//!   Body: the tool argument (string, sent as JSON-wrapped if not already JSON)
12//!
13//! Response handling:
14//!   - 2xx: response body becomes tool output (success)
15//!   - 4xx/5xx: error message with status code (failure)
16//!   - Connection error: descriptive error (failure)
17//!
18//! Timeout: parsed from ToolEntry.timeout field (e.g., "10s", "500ms").
19//! Default timeout: 30 seconds.
20//!
21//! §Fase 34.e (v1.29.0) — Streaming surface via [`HttpStreamingTool`].
22//! The async-trait Tool impl drives the upstream HTTP request via
23//! `reqwest::Client` (async) + drains the response body chunk-by-chunk.
24//! Content-Type drives framing:
25//!   - `text/event-stream` → per-W3C-SSE-event ToolChunks
26//!   - `application/x-ndjson` / `application/jsonl` → per-line ToolChunks
27//!   - Other (raw bytes, JSON, etc.) → single-chunk wrap (D9 backwards-
28//!     compat for non-streaming HTTP endpoints)
29//! Per-chunk cancel poll honors the D5 ≤100ms budget.
30
31use std::time::Duration;
32
33use crate::tool_executor::ToolResult;
34use crate::tool_registry::ToolEntry;
35
36// ── Timeout parsing ───────────────────────────────────────────────────────
37
38/// Parse a timeout string like "10s", "500ms", "2m" into Duration.
39/// Returns None for empty or unparseable values.
40fn parse_timeout(s: &str) -> Option<Duration> {
41    let s = s.trim();
42    if s.is_empty() {
43        return None;
44    }
45
46    if let Some(secs) = s.strip_suffix("ms") {
47        secs.trim().parse::<u64>().ok().map(Duration::from_millis)
48    } else if let Some(secs) = s.strip_suffix('s') {
49        secs.trim().parse::<u64>().ok().map(Duration::from_secs)
50    } else if let Some(mins) = s.strip_suffix('m') {
51        mins.trim()
52            .parse::<u64>()
53            .ok()
54            .map(|m| Duration::from_secs(m * 60))
55    } else {
56        // Try as raw seconds
57        s.parse::<u64>().ok().map(Duration::from_secs)
58    }
59}
60
61/// Public accessor for timeout parsing (used by emcp module).
62pub fn parse_timeout_pub(s: &str) -> Option<Duration> {
63    parse_timeout(s)
64}
65
66const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
67
68// ── HTTP dispatch ─────────────────────────────────────────────────────────
69
70/// Execute an HTTP tool call.
71///
72/// - `entry`: the tool's registry entry (must have provider == "http")
73/// - `argument`: the argument string from the use_tool step
74///
75/// Returns a ToolResult with the HTTP response body on success,
76/// or an error description on failure.
77pub fn dispatch_http(entry: &ToolEntry, argument: &str) -> ToolResult {
78    let url = entry.runtime.trim();
79
80    if url.is_empty() {
81        return ToolResult {
82            success: false,
83            output: format!(
84                "HTTP tool '{}': no endpoint URL. Set runtime: \"https://...\" in tool definition.",
85                entry.name
86            ),
87            tool_name: entry.name.clone(),
88        };
89    }
90
91    // Validate URL scheme
92    if !url.starts_with("http://") && !url.starts_with("https://") {
93        return ToolResult {
94            success: false,
95            output: format!(
96                "HTTP tool '{}': invalid URL '{}'. Must start with http:// or https://.",
97                entry.name, url
98            ),
99            tool_name: entry.name.clone(),
100        };
101    }
102
103    let timeout = parse_timeout(&entry.timeout).unwrap_or(DEFAULT_TIMEOUT);
104
105    // Build the request body — wrap as JSON string if not already JSON
106    let body = if argument.trim_start().starts_with('{') || argument.trim_start().starts_with('[') {
107        argument.to_string()
108    } else {
109        serde_json::json!({ "input": argument }).to_string()
110    };
111
112    // Execute the HTTP request
113    match execute_request(url, &entry.name, &body, timeout) {
114        Ok(response) => response,
115        Err(e) => ToolResult {
116            success: false,
117            output: format!("HTTP tool '{}': {}", entry.name, e),
118            tool_name: entry.name.clone(),
119        },
120    }
121}
122
123/// §Fase 114 (owed) — the PROCESS-SHARED blocking HTTP client. A fresh
124/// `reqwest::blocking::Client` per call threw away reqwest's internal connection
125/// pool, so every tool call paid a new TCP + TLS handshake. reqwest is explicitly
126/// designed to be built ONCE and reused (the pool lives on the `Client`); the
127/// per-call knob — the request timeout — is set per REQUEST instead, so one shared
128/// client serves every timeout. `http_tool` carries no per-resource client config
129/// (proxy / TLS impersonation is the `scrape` domain), so process-wide is the
130/// correct granularity here. Built lazily; `new()` only fails on a broken TLS
131/// backend, which would fail every call anyway.
132fn shared_blocking_client() -> &'static reqwest::blocking::Client {
133    static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
134    CLIENT.get_or_init(reqwest::blocking::Client::new)
135}
136
137/// §Fase 114 (owed) — the PROCESS-SHARED async HTTP client (the streaming-tool
138/// dual of [`shared_blocking_client`]). Same rationale: build once, reuse the
139/// connection pool, set the timeout per request.
140fn shared_async_client() -> &'static reqwest::Client {
141    static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
142    CLIENT.get_or_init(reqwest::Client::new)
143}
144
145/// Perform the actual HTTP POST request.
146fn execute_request(
147    url: &str,
148    tool_name: &str,
149    body: &str,
150    timeout: Duration,
151) -> Result<ToolResult, String> {
152    let response = shared_blocking_client()
153        .post(url)
154        .timeout(timeout)
155        .header("Content-Type", "application/json")
156        .header("X-Axon-Tool", tool_name)
157        .body(body.to_string())
158        .send()
159        .map_err(|e| {
160            if e.is_timeout() {
161                format!("request timed out after {}s", timeout.as_secs())
162            } else if e.is_connect() {
163                format!("connection failed to {url}")
164            } else {
165                format!("request failed: {e}")
166            }
167        })?;
168
169    let status = response.status();
170    let response_body = response
171        .text()
172        .map_err(|e| format!("failed to read response body: {e}"))?;
173
174    if status.is_success() {
175        Ok(ToolResult {
176            success: true,
177            output: response_body,
178            tool_name: tool_name.to_string(),
179        })
180    } else {
181        Ok(ToolResult {
182            success: false,
183            output: format!(
184                "HTTP {}: {}",
185                status.as_u16(),
186                if response_body.len() > 200 {
187                    format!("{}...", &response_body[..200])
188                } else {
189                    response_body
190                }
191            ),
192            tool_name: tool_name.to_string(),
193        })
194    }
195}
196
197// ════════════════════════════════════════════════════════════════════════════
198//  §Fase 34.e — HttpStreamingTool: async-trait Tool impl with per-chunk
199//  streaming wire emission via reqwest::Response::bytes_stream().
200// ════════════════════════════════════════════════════════════════════════════
201
202use async_trait::async_trait;
203use bytes::Bytes;
204use futures::StreamExt;
205
206use crate::backends::sse_streaming::{LineBuffer, SseEventParser};
207use crate::tool_trait::{Tool, ToolChunk, ToolContext, ToolFinishReason, ToolStream};
208
209/// HTTP tool with first-class streaming surface (Fase 34.e).
210///
211/// `stream()` drives a `reqwest::Client::post(url)` async request +
212/// drains `response.bytes_stream()` chunk-by-chunk. Content-Type
213/// header decides framing:
214///
215/// - **`text/event-stream`** → SSE per W3C spec. Each `data:` field
216///   from a complete SSE event emits as a `ToolChunk::intermediate`.
217///   `event:` / `id:` / `retry:` fields are dropped (the adopter sees
218///   only the data payload — the framing was for HTTP transport).
219/// - **`application/x-ndjson`** / **`application/jsonl`** → one
220///   `ToolChunk::intermediate` per LF-delimited line. Empty lines
221///   are skipped.
222/// - **Other** (`application/json`, `text/plain`, raw bytes) →
223///   single `ToolChunk::intermediate` with the full body accumulated,
224///   then a terminator. This is the D9 backwards-compat path for
225///   non-streaming HTTP endpoints — the same response shape
226///   [`dispatch_http`] returns synchronously, projected onto the
227///   single-chunk streaming surface.
228///
229/// # Cancel discipline (D5)
230///
231/// `ctx.cancel` is polled between every `bytes_stream().next().await`
232/// boundary. When fired, the stream drops the `reqwest::Response`
233/// (closing the connection) + emits a single
234/// `ToolFinishReason::Cancelled` terminator chunk. Wall-clock budget
235/// is bounded by the network roundtrip latency to the next chunk
236/// arrival (typically ≤100ms for SSE streams with regular keepalive).
237///
238/// # Error discipline
239///
240/// Every failure surface (URL invalid / client build / connect /
241/// timeout / non-2xx status / mid-stream byte error / I/O error)
242/// is captured as a `ToolFinishReason::Error { message }` terminator
243/// chunk — the consumer never sees a panic or a silently-truncated
244/// stream.
245pub struct HttpStreamingTool {
246    name: String,
247    url: String,
248    timeout: Duration,
249}
250
251impl HttpStreamingTool {
252    /// Construct from a registry [`ToolEntry`]. Validates the URL +
253    /// extracts the timeout. Returns `Err` with adopter-facing
254    /// diagnostic when the URL is missing or has an invalid scheme.
255    pub fn from_entry(entry: &ToolEntry) -> Result<Self, String> {
256        let url = entry.runtime.trim();
257        if url.is_empty() {
258            return Err(format!(
259                "HTTP tool '{}': no endpoint URL. Set runtime: \"https://...\" in tool definition.",
260                entry.name
261            ));
262        }
263        if !url.starts_with("http://") && !url.starts_with("https://") {
264            return Err(format!(
265                "HTTP tool '{}': invalid URL '{}'. Must start with http:// or https://.",
266                entry.name, url
267            ));
268        }
269        let timeout = parse_timeout(&entry.timeout).unwrap_or(DEFAULT_TIMEOUT);
270        Ok(Self {
271            name: entry.name.clone(),
272            url: url.to_string(),
273            timeout,
274        })
275    }
276
277    /// Public new() ctor for tests + adopters who construct directly
278    /// without a registry entry.
279    pub fn new(name: String, url: String, timeout: Duration) -> Self {
280        Self { name, url, timeout }
281    }
282}
283
284/// Build the request body — wrap as JSON `{ "input": args }` if not
285/// already JSON. Same logic as [`dispatch_http`].
286fn build_request_body(args: &str) -> String {
287    let trimmed = args.trim_start();
288    if trimmed.starts_with('{') || trimmed.starts_with('[') {
289        args.to_string()
290    } else {
291        serde_json::json!({ "input": args }).to_string()
292    }
293}
294
295/// Classify an HTTP Content-Type header into the framing mode the
296/// streaming tool will use.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298enum FramingMode {
299    /// W3C Server-Sent Events. Drain via [`LineBuffer`] +
300    /// [`SseEventParser`]; emit each event's `data:` field as a
301    /// `ToolChunk`.
302    Sse,
303    /// Newline-delimited JSON. Drain via [`LineBuffer`]; emit each
304    /// non-empty line as a `ToolChunk`.
305    Ndjson,
306    /// Anything else. Accumulate full body + emit as 1 chunk +
307    /// terminator. D9-style backwards-compat for non-streaming
308    /// HTTP endpoints.
309    Single,
310}
311
312fn classify_framing(content_type: &str) -> FramingMode {
313    let lc = content_type.to_ascii_lowercase();
314    if lc.contains("text/event-stream") {
315        FramingMode::Sse
316    } else if lc.contains("application/x-ndjson") || lc.contains("application/jsonl") {
317        FramingMode::Ndjson
318    } else {
319        FramingMode::Single
320    }
321}
322
323#[async_trait]
324impl Tool for HttpStreamingTool {
325    async fn execute(&self, args: String, _ctx: ToolContext) -> ToolResult {
326        // Synchronous path — adopters calling execute() directly get
327        // the legacy [`dispatch_http`] behavior verbatim. The
328        // streaming path drives `stream()` exclusively.
329        //
330        // [`dispatch_http`] uses `reqwest::blocking::Client` (it
331        // existed pre-async-trait). Calling blocking-reqwest from
332        // inside a tokio runtime panics; we wrap the call with
333        // `spawn_blocking` so the synchronous client runs on tokio's
334        // blocking pool. Output is byte-equal to dispatch_http (D9).
335        let entry = ToolEntry {
336            name: self.name.clone(),
337            provider: "http".to_string(),
338            timeout: format!("{}s", self.timeout.as_secs()),
339            runtime: self.url.clone(),
340            resource_ref: String::new(),
341            capacity: None,
342            sandbox: None,
343            max_results: None,
344            output_schema: String::new(),
345            effect_row: Vec::new(),
346            // §Fase 58.f.2 — reconstructed entry for the legacy sync
347            // delegate; no typed input schema needed on this path.
348            parameters: Vec::new(),
349            secret: String::new(),
350            secret_partition: String::new(),
351            source: crate::tool_registry::ToolSource::Program,
352            is_streaming: false,
353            scrape: None,
354        };
355        match tokio::task::spawn_blocking(move || dispatch_http(&entry, &args)).await {
356            Ok(result) => result,
357            Err(e) => ToolResult {
358                success: false,
359                output: format!("HTTP tool '{}': blocking task join failed: {e}", self.name),
360                tool_name: self.name.clone(),
361            },
362        }
363    }
364
365    async fn stream(&self, args: String, ctx: ToolContext) -> ToolStream {
366        let url = self.url.clone();
367        let name = self.name.clone();
368        let timeout = self.timeout;
369        let cancel = ctx.cancel.clone();
370        let body = build_request_body(&args);
371
372        // mpsc + spawn pattern: the background task drives the HTTP
373        // request + drains chunks into the channel; the returned
374        // stream wraps the receiver. This gives us real per-chunk
375        // streaming (chunks reach the dispatcher AS they arrive from
376        // upstream) without requiring async-stream macro.
377        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ToolChunk>();
378
379        tokio::spawn(async move {
380            // Helper: send the terminator + drop tx so the consumer's
381            // stream ends cleanly. Returning `Err` from a sub-step
382            // sends an Error-terminator; reaching the natural end of
383            // the body sends a Stop-terminator.
384            let send_terminator = |reason: ToolFinishReason| {
385                let _ = tx.send(ToolChunk::terminator("", reason));
386            };
387
388            // Pre-flight cancel check.
389            if cancel.is_cancelled() {
390                send_terminator(ToolFinishReason::Cancelled);
391                return;
392            }
393
394            // 1. The PROCESS-SHARED async client (§Fase 114 owed — see
395            //    `shared_blocking_client`: pooling was thrown away by a
396            //    per-call `Client::builder().build()`). Timeout is per REQUEST.
397            let client = shared_async_client();
398
399            // 2. Issue request.
400            let response = match client
401                .post(&url)
402                .timeout(timeout)
403                .header("Content-Type", "application/json")
404                .header("X-Axon-Tool", &name)
405                .body(body)
406                .send()
407                .await
408            {
409                Ok(r) => r,
410                Err(e) => {
411                    let message = if e.is_timeout() {
412                        format!(
413                            "HTTP tool '{name}': request timed out after {}s",
414                            timeout.as_secs()
415                        )
416                    } else if e.is_connect() {
417                        format!("HTTP tool '{name}': connection failed to {url}")
418                    } else {
419                        format!("HTTP tool '{name}': request failed: {e}")
420                    };
421                    send_terminator(ToolFinishReason::Error { message });
422                    return;
423                }
424            };
425
426            // 3. Non-2xx → error terminator with status code +
427            //    truncated body. Mirrors dispatch_http's diagnostic
428            //    shape.
429            let status = response.status();
430            if !status.is_success() {
431                let body_text = response.text().await.unwrap_or_default();
432                let truncated = if body_text.len() > 200 {
433                    format!("{}...", &body_text[..200])
434                } else {
435                    body_text
436                };
437                send_terminator(ToolFinishReason::Error {
438                    message: format!("HTTP {}: {}", status.as_u16(), truncated),
439                });
440                return;
441            }
442
443            // 4. Read Content-Type header → classify framing.
444            let content_type = response
445                .headers()
446                .get(reqwest::header::CONTENT_TYPE)
447                .and_then(|v| v.to_str().ok())
448                .unwrap_or("")
449                .to_string();
450            let framing = classify_framing(&content_type);
451
452            // 5. Drain the body byte-stream per framing mode.
453            let mut byte_stream = response.bytes_stream();
454            let drain_result = match framing {
455                FramingMode::Sse => {
456                    drain_sse(&mut byte_stream, &cancel, &tx).await
457                }
458                FramingMode::Ndjson => {
459                    drain_ndjson(&mut byte_stream, &cancel, &tx).await
460                }
461                FramingMode::Single => {
462                    drain_single(&mut byte_stream, &cancel, &tx).await
463                }
464            };
465
466            match drain_result {
467                DrainOutcome::Completed => send_terminator(ToolFinishReason::Stop),
468                DrainOutcome::Cancelled => send_terminator(ToolFinishReason::Cancelled),
469                DrainOutcome::Error(message) => {
470                    send_terminator(ToolFinishReason::Error { message })
471                }
472            }
473        });
474
475        // Wrap the receiver as a Stream. Each `recv().await` yields
476        // a ToolChunk + holds the channel open until the producer
477        // task drops `tx`.
478        Box::pin(futures::stream::unfold(rx, |mut rx| async move {
479            rx.recv().await.map(|chunk| (chunk, rx))
480        }))
481    }
482
483    fn is_streaming(&self) -> bool {
484        true
485    }
486}
487
488/// Per-framing-mode drain outcome. Drives the terminator decision
489/// in the spawned task without leaking implementation details.
490enum DrainOutcome {
491    Completed,
492    Cancelled,
493    Error(String),
494}
495
496/// Drain SSE framing. Reuses the battle-tested
497/// [`crate::backends::sse_streaming::LineBuffer`] +
498/// [`crate::backends::sse_streaming::SseEventParser`] from Fase 33.d
499/// so every adopter-emitted SSE shape (CRLF normalization, CR strip,
500/// multi-line data field, comment lines) is honored verbatim.
501async fn drain_sse<S>(
502    byte_stream: &mut S,
503    cancel: &crate::cancel_token::CancellationFlag,
504    tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
505) -> DrainOutcome
506where
507    S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
508{
509    let mut line_buf = LineBuffer::new();
510    let mut sse_parser = SseEventParser::new();
511    loop {
512        if cancel.is_cancelled() {
513            return DrainOutcome::Cancelled;
514        }
515        match byte_stream.next().await {
516            None => break,
517            Some(Err(e)) => {
518                return DrainOutcome::Error(format!("SSE stream chunk error: {e}"))
519            }
520            Some(Ok(bytes)) => {
521                let lines = line_buf.push(&bytes);
522                for line in lines {
523                    if let Some(event) = sse_parser.push_line(&line) {
524                        if let Some(data) = event.data {
525                            if tx
526                                .send(ToolChunk::intermediate(data))
527                                .is_err()
528                            {
529                                return DrainOutcome::Cancelled;
530                            }
531                        }
532                    }
533                }
534            }
535        }
536    }
537    // Flush trailing line (events without a final blank-line
538    // terminator) — push it into the parser; if it completes an
539    // event, emit it.
540    if let Some(line) = line_buf.flush() {
541        if let Some(event) = sse_parser.push_line(&line) {
542            if let Some(data) = event.data {
543                let _ = tx.send(ToolChunk::intermediate(data));
544            }
545        }
546    }
547    DrainOutcome::Completed
548}
549
550/// Drain NDJSON framing. Each LF-delimited line emits as a
551/// `ToolChunk::intermediate`. Empty lines are skipped per
552/// `application/x-ndjson` spec.
553async fn drain_ndjson<S>(
554    byte_stream: &mut S,
555    cancel: &crate::cancel_token::CancellationFlag,
556    tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
557) -> DrainOutcome
558where
559    S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
560{
561    let mut line_buf = LineBuffer::new();
562    loop {
563        if cancel.is_cancelled() {
564            return DrainOutcome::Cancelled;
565        }
566        match byte_stream.next().await {
567            None => break,
568            Some(Err(e)) => {
569                return DrainOutcome::Error(format!("NDJSON stream chunk error: {e}"))
570            }
571            Some(Ok(bytes)) => {
572                let lines = line_buf.push(&bytes);
573                for line in lines {
574                    if !line.is_empty()
575                        && tx.send(ToolChunk::intermediate(line)).is_err()
576                    {
577                        return DrainOutcome::Cancelled;
578                    }
579                }
580            }
581        }
582    }
583    if let Some(line) = line_buf.flush() {
584        if !line.is_empty() {
585            let _ = tx.send(ToolChunk::intermediate(line));
586        }
587    }
588    DrainOutcome::Completed
589}
590
591/// Drain single-chunk framing. Accumulate the full body + emit as
592/// 1 `ToolChunk::intermediate` (terminator follows from the caller).
593/// D9 backwards-compat for non-streaming HTTP endpoints.
594async fn drain_single<S>(
595    byte_stream: &mut S,
596    cancel: &crate::cancel_token::CancellationFlag,
597    tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
598) -> DrainOutcome
599where
600    S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
601{
602    let mut acc: Vec<u8> = Vec::new();
603    loop {
604        if cancel.is_cancelled() {
605            return DrainOutcome::Cancelled;
606        }
607        match byte_stream.next().await {
608            None => break,
609            Some(Err(e)) => {
610                return DrainOutcome::Error(format!("HTTP body chunk error: {e}"))
611            }
612            Some(Ok(bytes)) => acc.extend_from_slice(&bytes),
613        }
614    }
615    let body_text = String::from_utf8_lossy(&acc).into_owned();
616    if !body_text.is_empty()
617        && tx
618            .send(ToolChunk::intermediate(body_text))
619            .is_err()
620    {
621        return DrainOutcome::Cancelled;
622    }
623    DrainOutcome::Completed
624}
625
626// ── Tests ─────────────────────────────────────────────────────────────────
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::tool_registry::{ToolEntry, ToolSource};
632
633    /// §Fase 114 (owed) — **the HTTP client is POOLED: every call reuses ONE
634    /// client, not a fresh one per request.** reqwest's connection pool lives on
635    /// the `Client`; a fresh `Client::builder().build()` per call discarded it, so
636    /// every tool call paid a new TCP + TLS handshake. This pins the fix directly:
637    /// the shared accessor returns the SAME instance across calls — one client,
638    /// one pool. (Connection reuse from a shared client is reqwest's documented
639    /// behavior; what this crate owns is *not rebuilding the client per call*.)
640    #[test]
641    fn the_blocking_http_client_is_a_reused_singleton() {
642        let a = shared_blocking_client();
643        let b = shared_blocking_client();
644        assert!(
645            std::ptr::eq(a, b),
646            "every call must reuse ONE blocking client (its connection pool) — not build a fresh \
647             one per request"
648        );
649    }
650
651    #[test]
652    fn the_async_http_client_is_a_reused_singleton() {
653        let a = shared_async_client();
654        let b = shared_async_client();
655        assert!(
656            std::ptr::eq(a, b),
657            "every call must reuse ONE async client (its connection pool)"
658        );
659    }
660
661    fn make_http_entry(name: &str, url: &str, timeout: &str) -> ToolEntry {
662        ToolEntry {
663            name: name.to_string(),
664            provider: "http".to_string(),
665            timeout: timeout.to_string(),
666            runtime: url.to_string(),
667            resource_ref: String::new(),
668            capacity: None,
669            sandbox: None,
670            max_results: None,
671            output_schema: "JSON".to_string(),
672            effect_row: vec!["network".to_string()],
673            parameters: Vec::new(),
674            secret: String::new(),
675            secret_partition: String::new(),
676            source: ToolSource::Program,
677            // §Fase 34.c — HTTP tools default to non-streaming; effect_row
678            // carries `network` but no `stream:` prefix. HTTP streaming
679            // (SSE-aware adapter consuming upstream SSE) lands in Fase 34.e.
680            is_streaming: false,
681            scrape: None,
682        }
683    }
684
685    // ── Timeout parsing ───────────────────────────────────────────
686
687    #[test]
688    fn parse_timeout_seconds() {
689        assert_eq!(parse_timeout("10s"), Some(Duration::from_secs(10)));
690        assert_eq!(parse_timeout("30s"), Some(Duration::from_secs(30)));
691    }
692
693    #[test]
694    fn parse_timeout_milliseconds() {
695        assert_eq!(parse_timeout("500ms"), Some(Duration::from_millis(500)));
696        assert_eq!(parse_timeout("100ms"), Some(Duration::from_millis(100)));
697    }
698
699    #[test]
700    fn parse_timeout_minutes() {
701        assert_eq!(parse_timeout("2m"), Some(Duration::from_secs(120)));
702    }
703
704    #[test]
705    fn parse_timeout_raw_number() {
706        assert_eq!(parse_timeout("15"), Some(Duration::from_secs(15)));
707    }
708
709    #[test]
710    fn parse_timeout_empty() {
711        assert_eq!(parse_timeout(""), None);
712        assert_eq!(parse_timeout("  "), None);
713    }
714
715    #[test]
716    fn parse_timeout_invalid() {
717        assert_eq!(parse_timeout("abc"), None);
718        assert_eq!(parse_timeout("10x"), None);
719    }
720
721    // ── URL validation ────────────────────────────────────────────
722
723    #[test]
724    fn dispatch_empty_url_fails() {
725        let entry = make_http_entry("DataAPI", "", "10s");
726        let result = dispatch_http(&entry, "test query");
727        assert!(!result.success);
728        assert!(result.output.contains("no endpoint URL"));
729    }
730
731    #[test]
732    fn dispatch_invalid_url_scheme_fails() {
733        let entry = make_http_entry("DataAPI", "ftp://example.com", "10s");
734        let result = dispatch_http(&entry, "test query");
735        assert!(!result.success);
736        assert!(result.output.contains("invalid URL"));
737        assert!(result.output.contains("http://"));
738    }
739
740    // ── Connection errors (no server) ─────────────────────────────
741
742    #[test]
743    fn dispatch_connection_refused() {
744        // Port 1 is almost certainly not listening
745        let entry = make_http_entry("TestTool", "http://127.0.0.1:1/api", "2s");
746        let result = dispatch_http(&entry, "test");
747        assert!(!result.success);
748        assert!(
749            result.output.contains("connection failed")
750                || result.output.contains("request failed")
751                || result.output.contains("timed out"),
752            "unexpected error: {}",
753            result.output
754        );
755    }
756
757    // ── Body wrapping ─────────────────────────────────────────────
758
759    #[test]
760    fn json_body_passthrough() {
761        // If argument is already JSON, it should be sent as-is
762        let arg = r#"{"query": "test"}"#;
763        let body = if arg.trim_start().starts_with('{') {
764            arg.to_string()
765        } else {
766            serde_json::json!({ "input": arg }).to_string()
767        };
768        assert_eq!(body, r#"{"query": "test"}"#);
769    }
770
771    #[test]
772    fn plain_text_wrapped() {
773        // If argument is plain text, it should be wrapped
774        let arg = "search for cats";
775        let body = if arg.trim_start().starts_with('{') || arg.trim_start().starts_with('[') {
776            arg.to_string()
777        } else {
778            serde_json::json!({ "input": arg }).to_string()
779        };
780        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
781        assert_eq!(parsed["input"], "search for cats");
782    }
783
784    #[test]
785    fn array_body_passthrough() {
786        let arg = r#"[1, 2, 3]"#;
787        let body = if arg.trim_start().starts_with('{') || arg.trim_start().starts_with('[') {
788            arg.to_string()
789        } else {
790            serde_json::json!({ "input": arg }).to_string()
791        };
792        assert_eq!(body, "[1, 2, 3]");
793    }
794}