1use std::io::{Read, Write};
17use std::net::{TcpStream, ToSocketAddrs};
18use std::time::{Duration, Instant};
19
20use regex::Regex;
21use serde_json::{json, Value};
22
23use crate::formal_ai_files::{describe_file_call, file_tool, normalize_file_args, FileArgs};
24use crate::formal_ai_policy::{EXIT_BUDGET, EXIT_PROTOCOL, EXIT_UNAVAILABLE};
25
26pub const API_PREFIX: &str = "/api/openai/v1";
27pub const BASH_NAMES: [&str; 3] = ["bash", "shell", "run_command"];
29pub const TOOL_NAMES: [&str; 6] = ["bash", "shell", "run_command", "read", "edit", "write"];
31const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
32const POLL: Duration = Duration::from_millis(100);
33
34pub fn bash_tool() -> Value {
36 json!({
37 "type": "function",
38 "function": {
39 "name": "bash",
40 "description": "Run one command in the working directory. Pipes, redirects, globs and variables are not supported.",
41 "parameters": {
42 "type": "object",
43 "properties": { "command": { "type": "string" } },
44 "required": ["command"],
45 },
46 },
47 })
48}
49
50pub fn offered_tools(effects: &str) -> Vec<Value> {
53 let names: &[&str] = if effects == "workspace-write" {
54 &["read", "edit", "write"]
55 } else {
56 &["read"]
57 };
58 std::iter::once(bash_tool())
59 .chain(names.iter().map(|name| file_tool(name)))
60 .collect()
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub struct ClientError {
66 pub message: String,
67 pub exit_code: i32,
68 pub cancelled: bool,
69 pub details: Option<Value>,
72}
73
74impl ClientError {
75 pub fn protocol(message: impl Into<String>) -> Self {
76 Self::with_code(message, EXIT_PROTOCOL)
77 }
78
79 pub fn with_code(message: impl Into<String>, exit_code: i32) -> Self {
80 Self {
81 message: message.into(),
82 exit_code,
83 cancelled: false,
84 details: None,
85 }
86 }
87
88 pub fn with_details(mut self, details: Value) -> Self {
89 self.details = Some(details);
90 self
91 }
92}
93
94const PREVIEW_CHARS: usize = 500;
95const MESSAGE_CHARS: usize = 300;
96const REDACTED: &str = "[REDACTED]";
97
98fn sanitizers() -> &'static [(Regex, &'static str); 4] {
99 static PATTERNS: std::sync::OnceLock<[(Regex, &'static str); 4]> = std::sync::OnceLock::new();
100 PATTERNS.get_or_init(|| {
101 let compile = |pattern: &str| Regex::new(pattern).expect("valid sanitizer pattern");
102 [
103 (
104 compile(r#"(?i)((?:proxy-)?authorization["']?\s*[:=]\s*["']?)[^"'\r\n,;}]*"#),
105 "${1}[REDACTED]",
106 ),
107 (
108 compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{4,}"),
109 "${1} [REDACTED]",
110 ),
111 (
112 compile(
113 r#"(?i)((?:api[_-]?key|access[_-]?token|token|secret|password|passwd)["']?\s*[:=]\s*["']?)[^"'\s,;}]+"#,
114 ),
115 "${1}[REDACTED]",
116 ),
117 (compile(r"[\x00-\x1f\x7f]+"), " "),
118 ]
119 })
120}
121
122pub fn sanitize_diagnostic(text: &str, limit: usize, secrets: &[&str]) -> String {
126 let mut clean = text.to_string();
127 for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
128 clean = clean.replace(secret, REDACTED);
129 }
130 for (pattern, replacement) in sanitizers() {
131 clean = pattern.replace_all(&clean, *replacement).to_string();
132 }
133 let clean = clean.trim();
134 let count = clean.chars().count();
135 if count > limit {
136 format!(
137 "{}… [{} more chars]",
138 clean.chars().take(limit).collect::<String>(),
139 count - limit
140 )
141 } else {
142 clean.to_string()
143 }
144}
145
146pub fn http_error_details(status: u16, text: &str, secrets: &[&str]) -> Value {
149 let body: Value = serde_json::from_str(text).unwrap_or(Value::Null);
150 let error = body.get("error").unwrap_or(&Value::Null);
151 let (kind, message) = if error.is_object() {
152 (
153 error.get("type").and_then(Value::as_str),
154 error.get("message").and_then(Value::as_str),
155 )
156 } else if let Some(text) = error.as_str() {
157 (None, Some(text))
158 } else {
159 (None, body.get("message").and_then(Value::as_str))
160 };
161 json!({
162 "httpStatus": status,
163 "errorType": kind.map(|kind| sanitize_diagnostic(kind, 100, secrets)),
164 "errorMessage": message.map(|message| sanitize_diagnostic(message, MESSAGE_CHARS, secrets)),
165 "bodyPreview": sanitize_diagnostic(text, PREVIEW_CHARS, secrets),
166 "bodyChars": text.encode_utf16().count(),
167 })
168}
169
170pub fn http_error_message(details: &Value) -> String {
172 let text = |key: &str| details[key].as_str().filter(|text| !text.is_empty());
173 let cause = match (text("errorType"), text("errorMessage")) {
174 (Some(kind), Some(message)) => format!("{}: {}", kind, message),
175 (None, Some(message)) => message.to_string(),
176 _ => {
177 let preview: String = text("bodyPreview")
178 .unwrap_or("")
179 .chars()
180 .take(MESSAGE_CHARS)
181 .collect();
182 if preview.is_empty() {
183 "(empty body)".to_string()
184 } else {
185 preview
186 }
187 }
188 };
189 format!(
190 "Formal AI returned HTTP {}: {}",
191 details["httpStatus"], cause
192 )
193}
194
195pub const GAP_SCHEMAS: [(&str, &[&str]); 2] = [
197 (
198 "workspace-edit-capability-gap/v1",
199 &["EditToolUnavailable", "ReadToolUnavailable"],
200 ),
201 (
204 "workspace-change-verification-gap/v1",
205 &[
206 "VerificationRunToolUnavailable",
207 "SourcePreimageUnavailable",
208 ],
209 ),
210];
211const CAPABILITIES: [&str; 6] = ["read", "edit", "multi_edit", "write", "run", "bash"];
212
213pub fn recognize_capability_gap(content: &str, messages: &[Value]) -> Option<Value> {
218 let text = content.trim();
219 if !text.starts_with('{') || !text.ends_with('}') {
220 return None;
221 }
222 let gap: Value = serde_json::from_str(text).ok()?;
223 let schema = gap.get("schema").and_then(Value::as_str)?;
224 let (_, reasons) = GAP_SCHEMAS.iter().find(|(name, _)| *name == schema)?;
225 let reason = gap.get("reason").and_then(Value::as_str)?;
226 let target = gap.get("target").and_then(Value::as_str)?;
227 let missing = match gap.get("missingCapabilities") {
228 None => Vec::new(),
229 Some(value) => value.as_array()?.clone(),
230 };
231 let known = missing
232 .iter()
233 .all(|c| c.as_str().is_some_and(|c| CAPABILITIES.contains(&c)));
234 if !reasons.contains(&reason)
235 || gap.get("complete") != Some(&Value::Bool(false))
236 || gap.get("verified") != Some(&Value::Bool(false))
237 || target.trim().is_empty()
238 || !known
239 {
240 return None;
241 }
242 if crate::formal_ai_files::echoed_by_tool(text, messages) {
243 return None;
244 }
245 Some(json!({
246 "schema": schema,
247 "target": target,
248 "reason": reason,
249 "missingCapabilities": missing,
250 "complete": false,
251 "verified": false,
252 }))
253}
254
255#[derive(Debug, Clone, PartialEq, Default)]
257pub struct ToolCall {
258 pub id: Option<String>,
259 pub name: String,
261 pub tool: String,
263 pub command: String,
265 pub arguments: String,
267 pub args: Option<FileArgs>,
269}
270
271#[derive(Debug, Clone, PartialEq, Default)]
273pub struct Completion {
274 pub content: String,
275 pub finish_reason: Option<String>,
276 pub tool_calls: Vec<ToolCall>,
277 pub handler: Option<String>,
278}
279
280fn value_text(value: &Value) -> String {
281 match value {
282 Value::String(text) => text.clone(),
283 other => other.to_string(),
284 }
285}
286
287pub fn parse_completion(body: &Value) -> Result<Completion, ClientError> {
289 let choice = body
290 .get("choices")
291 .and_then(Value::as_array)
292 .and_then(|choices| choices.first())
293 .filter(|choice| choice.get("message").is_some_and(Value::is_object))
294 .ok_or_else(|| ClientError::protocol("Formal AI response has no choices[0].message"))?;
295 let message = &choice["message"];
296 let handler = message
297 .get("thinking_steps")
298 .and_then(Value::as_array)
299 .and_then(|steps| {
300 steps
301 .iter()
302 .find(|s| s.get("step").and_then(Value::as_str) == Some("dispatch_handler"))
303 })
304 .map(|step| value_text(step.get("detail").unwrap_or(&Value::Null)));
305 let mut tool_calls = Vec::new();
306 if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
307 for call in calls {
308 let function = call.get("function");
309 let name = function
310 .and_then(|f| f.get("name"))
311 .and_then(Value::as_str)
312 .unwrap_or("");
313 if !TOOL_NAMES.contains(&name) {
314 let shown = if name.is_empty() { "undefined" } else { name };
315 return Err(ClientError::protocol(format!(
316 "Formal AI called an unsupported tool \"{}\"",
317 shown
318 )));
319 }
320 let raw = function
321 .and_then(|f| f.get("arguments"))
322 .and_then(Value::as_str)
323 .filter(|text| !text.is_empty())
324 .unwrap_or("{}");
325 let args: Value = serde_json::from_str(raw).map_err(|_| {
326 ClientError::protocol(format!("Formal AI sent non-JSON arguments for {}", name))
327 })?;
328 let id = call
329 .get("id")
330 .and_then(Value::as_str)
331 .filter(|id| !id.is_empty())
332 .map(str::to_string);
333 if !BASH_NAMES.contains(&name) {
334 let normalized = normalize_file_args(name, &args).map_err(|problem| {
335 ClientError::protocol(format!("Formal AI called {}", problem))
336 })?;
337 tool_calls.push(ToolCall {
338 id,
339 name: name.to_string(),
340 tool: name.to_string(),
341 command: describe_file_call(name, &normalized),
342 arguments: raw.to_string(),
343 args: Some(normalized),
344 });
345 continue;
346 }
347 let command = args
348 .get("command")
349 .filter(|v| !v.is_null())
350 .or_else(|| args.get("cmd"))
351 .and_then(Value::as_str)
352 .filter(|text| !text.trim().is_empty())
353 .ok_or_else(|| {
354 ClientError::protocol(format!("Formal AI called {} without a command", name))
355 })?;
356 tool_calls.push(ToolCall {
357 id,
358 name: name.to_string(),
359 tool: "bash".to_string(),
360 command: command.to_string(),
361 ..ToolCall::default()
362 });
363 }
364 }
365 Ok(Completion {
366 content: message
367 .get("content")
368 .and_then(Value::as_str)
369 .unwrap_or("")
370 .to_string(),
371 finish_reason: choice
372 .get("finish_reason")
373 .and_then(Value::as_str)
374 .filter(|reason| !reason.is_empty())
375 .map(str::to_string),
376 tool_calls,
377 handler,
378 })
379}
380
381pub trait CompletionClient {
383 fn complete(
385 &mut self,
386 messages: &[Value],
387 cancel: &dyn Fn() -> bool,
388 ) -> Result<Completion, ClientError>;
389 fn requests(&self) -> u64;
391}
392
393#[derive(Debug, Clone, PartialEq)]
395pub enum HttpError {
396 Timeout,
397 Cancelled,
398 Io(String),
399}
400
401pub fn parse_http_url(url: &str) -> Result<(String, u16, String), String> {
403 let rest = url
404 .strip_prefix("http://")
405 .ok_or_else(|| format!("not an http:// URL: {}", url))?;
406 let (authority, prefix) = match rest.find('/') {
407 Some(index) => (&rest[..index], rest[index..].trim_end_matches('/')),
408 None => (rest, ""),
409 };
410 let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
411 let end = stripped
412 .find(']')
413 .ok_or_else(|| format!("invalid host in {}", url))?;
414 let port = stripped[end + 1..].strip_prefix(':').unwrap_or("80");
415 (stripped[..end].to_string(), port)
416 } else {
417 match authority.rsplit_once(':') {
418 Some((host, port)) => (host.to_string(), port),
419 None => (authority.to_string(), "80"),
420 }
421 };
422 if host.is_empty() {
423 return Err(format!("missing host in {}", url));
424 }
425 let port = port
426 .parse::<u16>()
427 .map_err(|_| format!("invalid port in {}", url))?;
428 Ok((host, port, prefix.to_string()))
429}
430
431fn header_end(buf: &[u8]) -> Option<usize> {
432 buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)
433}
434
435fn decode_chunked(mut body: &[u8]) -> Option<Vec<u8>> {
437 let mut out = Vec::new();
438 loop {
439 let line_end = body.windows(2).position(|w| w == b"\r\n")?;
440 let size_text = String::from_utf8_lossy(&body[..line_end]);
441 let size_text = size_text.split(';').next().unwrap_or("").trim();
442 let size = usize::from_str_radix(size_text, 16).ok()?;
443 body = &body[line_end + 2..];
444 if size == 0 {
445 return Some(out);
446 }
447 if body.len() < size + 2 {
448 return None;
449 }
450 out.extend_from_slice(&body[..size]);
451 body = &body[size + 2..];
452 }
453}
454
455struct Head {
456 status: u16,
457 content_length: Option<usize>,
458 chunked: bool,
459}
460
461fn parse_head(head: &[u8]) -> Result<Head, HttpError> {
462 let text = String::from_utf8_lossy(head);
463 let mut lines = text.split("\r\n");
464 let status = lines
465 .next()
466 .and_then(|line| line.split_whitespace().nth(1))
467 .and_then(|code| code.parse::<u16>().ok())
468 .ok_or_else(|| HttpError::Io("malformed HTTP response".to_string()))?;
469 let mut content_length = None;
470 let mut chunked = false;
471 for line in lines {
472 if let Some((name, value)) = line.split_once(':') {
473 let name = name.trim().to_ascii_lowercase();
474 let value = value.trim();
475 if name == "content-length" {
476 content_length = value.parse::<usize>().ok();
477 } else if name == "transfer-encoding" && value.to_ascii_lowercase().contains("chunked")
478 {
479 chunked = true;
480 }
481 }
482 }
483 Ok(Head {
484 status,
485 content_length,
486 chunked,
487 })
488}
489
490fn complete_body(buf: &[u8], closed: bool) -> Result<Option<(u16, Vec<u8>)>, HttpError> {
492 let Some(end) = header_end(buf) else {
493 return if closed {
494 Err(HttpError::Io(
495 "connection closed before a response".to_string(),
496 ))
497 } else {
498 Ok(None)
499 };
500 };
501 let head = parse_head(&buf[..end])?;
502 let body = &buf[end..];
503 if head.chunked {
504 return match decode_chunked(body) {
505 Some(decoded) => Ok(Some((head.status, decoded))),
506 None if closed => Err(HttpError::Io("truncated chunked response".to_string())),
507 None => Ok(None),
508 };
509 }
510 match head.content_length {
511 Some(length) if body.len() >= length => Ok(Some((head.status, body[..length].to_vec()))),
512 Some(_) if closed => Err(HttpError::Io("truncated response".to_string())),
513 Some(_) => Ok(None),
514 None if closed => Ok(Some((head.status, body.to_vec()))),
515 None => Ok(None),
516 }
517}
518
519fn io_error(error: std::io::Error) -> HttpError {
520 HttpError::Io(error.to_string())
521}
522
523pub fn http_request(
525 base_url: &str,
526 method: &str,
527 path: &str,
528 body: Option<&str>,
529 timeout: Duration,
530 cancel: &dyn Fn() -> bool,
531) -> Result<(u16, String), HttpError> {
532 let (host, port, prefix) = parse_http_url(base_url).map_err(HttpError::Io)?;
533 let deadline = Instant::now() + timeout;
534 let remaining = || deadline.saturating_duration_since(Instant::now());
535 let mut last = HttpError::Io(format!("cannot resolve {}", host));
536 let mut stream = None;
537 for addr in (host.as_str(), port).to_socket_addrs().map_err(io_error)? {
538 if remaining().is_zero() {
539 return Err(HttpError::Timeout);
540 }
541 match TcpStream::connect_timeout(&addr, remaining()) {
542 Ok(connected) => {
543 stream = Some(connected);
544 break;
545 }
546 Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
547 last = HttpError::Timeout;
548 }
549 Err(error) => last = io_error(error),
550 }
551 }
552 let mut stream = stream.ok_or(last)?;
553 let mut request = format!(
554 "{} {}{} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\nAccept: application/json\r\n",
555 method, prefix, path, host, port
556 );
557 if let Some(body) = body {
558 request.push_str(&format!(
559 "Content-Type: application/json\r\nContent-Length: {}\r\n",
560 body.len()
561 ));
562 }
563 request.push_str("\r\n");
564 request.push_str(body.unwrap_or(""));
565 stream
566 .set_write_timeout(Some(remaining().max(Duration::from_millis(1))))
567 .map_err(io_error)?;
568 stream.write_all(request.as_bytes()).map_err(io_error)?;
569 stream.set_read_timeout(Some(POLL)).map_err(io_error)?;
570 let mut buf = Vec::new();
571 let mut chunk = [0u8; 16 * 1024];
572 loop {
573 if cancel() {
574 return Err(HttpError::Cancelled);
575 }
576 if remaining().is_zero() {
577 return Err(HttpError::Timeout);
578 }
579 match stream.read(&mut chunk) {
580 Ok(0) => {
581 let (status, body) = complete_body(&buf, true)?.unwrap_or_default();
582 return Ok((status, String::from_utf8_lossy(&body).to_string()));
583 }
584 Ok(n) => {
585 buf.extend_from_slice(&chunk[..n]);
586 if buf.len() > MAX_RESPONSE_BYTES {
587 return Err(HttpError::Io("response too large".to_string()));
588 }
589 if let Some((status, body)) = complete_body(&buf, false)? {
590 return Ok((status, String::from_utf8_lossy(&body).to_string()));
591 }
592 }
593 Err(error)
594 if matches!(
595 error.kind(),
596 std::io::ErrorKind::WouldBlock
597 | std::io::ErrorKind::TimedOut
598 | std::io::ErrorKind::Interrupted
599 ) => {}
600 Err(error) => return Err(io_error(error)),
601 }
602 }
603}
604
605pub struct HttpClient {
607 pub base_url: String,
608 pub max_retries: u64,
609 pub deadline: Instant,
610 pub requests: u64,
611 pub tools: Vec<Value>,
613}
614
615impl HttpClient {
616 pub fn new(base_url: &str, max_retries: u64, deadline: Instant) -> Self {
618 Self {
619 base_url: base_url.to_string(),
620 max_retries,
621 deadline,
622 requests: 0,
623 tools: offered_tools("read-only"),
624 }
625 }
626
627 pub fn with_tools(mut self, tools: Vec<Value>) -> Self {
629 self.tools = tools;
630 self
631 }
632}
633
634fn budget_error() -> ClientError {
635 ClientError::with_code(
636 "time budget exhausted while waiting for Formal AI",
637 EXIT_BUDGET,
638 )
639}
640
641impl CompletionClient for HttpClient {
642 fn complete(
643 &mut self,
644 messages: &[Value],
645 cancel: &dyn Fn() -> bool,
646 ) -> Result<Completion, ClientError> {
647 let body = json!({
648 "model": "formal-ai",
649 "messages": messages,
650 "tools": self.tools,
651 })
652 .to_string();
653 let mut last_error = ClientError::protocol("Formal AI was not asked");
654 let mut attempts: u64 = 0;
655 for _ in 0..=self.max_retries {
656 let remaining = self.deadline.saturating_duration_since(Instant::now());
657 if remaining.is_zero() {
658 return Err(budget_error());
659 }
660 self.requests += 1;
661 attempts += 1;
662 let path = "/chat/completions";
663 let full = format!("{}{}", API_PREFIX, path);
664 match http_request(
665 &self.base_url,
666 "POST",
667 &full,
668 Some(&body),
669 remaining,
670 cancel,
671 ) {
672 Ok((status, text)) => {
673 if !(200..300).contains(&status) {
674 let mut details = http_error_details(status, &text, &[]);
675 details["attempts"] = json!(attempts);
676 let error = ClientError::protocol(http_error_message(&details))
677 .with_details(details);
678 if status >= 500 {
681 last_error = error;
682 continue;
683 }
684 return Err(error);
685 }
686 let parsed: Value = serde_json::from_str(&text).map_err(|_| {
687 ClientError::protocol("Formal AI returned a non-JSON response")
688 })?;
689 return parse_completion(&parsed);
690 }
691 Err(HttpError::Cancelled) => {
692 let mut error = ClientError::protocol("cancelled");
693 error.cancelled = true;
694 return Err(error);
695 }
696 Err(HttpError::Timeout) => return Err(budget_error()),
697 Err(HttpError::Io(message)) => {
698 let cause = sanitize_diagnostic(&message, MESSAGE_CHARS, &[]);
699 last_error = ClientError::with_code(
700 format!("Formal AI request failed: {}", cause),
701 EXIT_UNAVAILABLE,
702 )
703 .with_details(json!({ "cause": cause, "attempts": attempts }));
704 }
705 }
706 }
707 let retries = attempts.saturating_sub(1);
708 if retries > 0 {
709 let plural = if retries == 1 { "y" } else { "ies" };
710 last_error.message =
711 format!("{} (after {} retr{})", last_error.message, retries, plural);
712 }
713 if let Some(details) = last_error.details.as_mut() {
714 details["attempts"] = json!(attempts);
715 }
716 Err(last_error)
717 }
718
719 fn requests(&self) -> u64 {
720 self.requests
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn parses_tool_calls_and_handler() {
730 let body = json!({"choices": [{"finish_reason": "tool_calls", "message": {
731 "content": "",
732 "tool_calls": [{"id": "c1", "function": {"name": "bash", "arguments": "{\"command\":\"ls\"}"}}],
733 "thinking_steps": [{"step": "dispatch_handler", "detail": "list_files"}]
734 }}]});
735 let completion = parse_completion(&body).unwrap();
736 assert_eq!(completion.handler.as_deref(), Some("list_files"));
737 assert_eq!(completion.finish_reason.as_deref(), Some("tool_calls"));
738 assert_eq!(
739 completion.tool_calls,
740 vec![ToolCall {
741 id: Some("c1".to_string()),
742 name: "bash".to_string(),
743 tool: "bash".to_string(),
744 command: "ls".to_string(),
745 ..ToolCall::default()
746 }]
747 );
748 }
749
750 #[test]
751 fn rejects_malformed_completions() {
752 let cases = [
753 (json!({}), "Formal AI response has no choices[0].message"),
754 (
755 json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "python", "arguments": "{}"}}]}}]}),
756 "Formal AI called an unsupported tool \"python\"",
757 ),
758 (
759 json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "bash", "arguments": "ls"}}]}}]}),
760 "Formal AI sent non-JSON arguments for bash",
761 ),
762 (
763 json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "shell", "arguments": "{\"cmd\":\" \"}"}}]}}]}),
764 "Formal AI called shell without a command",
765 ),
766 ];
767 for (body, message) in cases {
768 let error = parse_completion(&body).unwrap_err();
769 assert_eq!(error.message, message);
770 assert_eq!(error.exit_code, EXIT_PROTOCOL);
771 }
772 }
773
774 #[test]
775 fn accepts_cmd_arguments() {
776 let body = json!({"choices": [{"message": {"content": "ok",
777 "tool_calls": [{"function": {"name": "run_command", "arguments": "{\"cmd\":\"pwd\"}"}}]}}]});
778 let completion = parse_completion(&body).unwrap();
779 assert_eq!(completion.tool_calls[0].command, "pwd");
780 assert_eq!(completion.tool_calls[0].id, None);
781 assert_eq!(completion.content, "ok");
782 }
783
784 #[test]
785 fn parses_http_urls() {
786 assert_eq!(
787 parse_http_url("http://127.0.0.1:8080").unwrap(),
788 ("127.0.0.1".to_string(), 8080, String::new())
789 );
790 assert_eq!(
791 parse_http_url("http://localhost/base/").unwrap(),
792 ("localhost".to_string(), 80, "/base".to_string())
793 );
794 assert_eq!(
795 parse_http_url("http://[::1]:9/").unwrap(),
796 ("::1".to_string(), 9, String::new())
797 );
798 assert!(parse_http_url("https://example.com").is_err());
799 assert!(parse_http_url("http://:80").is_err());
800 }
801
802 #[test]
803 fn decodes_content_length_and_chunked_bodies() {
804 let fixed = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nokEXTRA";
805 assert_eq!(
806 complete_body(fixed, false).unwrap(),
807 Some((200, b"ok".to_vec()))
808 );
809 let partial = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nok";
810 assert_eq!(complete_body(partial, false).unwrap(), None);
811 assert!(complete_body(partial, true).is_err());
812 let chunked = b"HTTP/1.1 503 Busy\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n";
813 assert_eq!(
814 complete_body(chunked, false).unwrap(),
815 Some((503, b"abcde".to_vec()))
816 );
817 let open = b"HTTP/1.1 200 OK\r\n\r\nbody";
818 assert_eq!(complete_body(open, false).unwrap(), None);
819 assert_eq!(
820 complete_body(open, true).unwrap(),
821 Some((200, b"body".to_vec()))
822 );
823 }
824
825 #[test]
826 fn unreachable_server_is_unavailable_after_retries() {
827 let mut client = HttpClient::new(
829 "http://127.0.0.1:9",
830 1,
831 Instant::now() + Duration::from_secs(10),
832 );
833 let error = client.complete(&[], &|| false).unwrap_err();
834 assert_eq!(client.requests(), 2);
835 if error.exit_code == EXIT_UNAVAILABLE {
836 assert!(error.message.starts_with("Formal AI request failed: "));
837 assert!(error.message.ends_with(" (after 1 retry)"));
838 } else {
839 assert_eq!(error.exit_code, EXIT_BUDGET);
840 }
841 }
842}
843
844#[cfg(test)]
845#[path = "formal_ai_client_cases.rs"]
846mod formal_ai_client_cases;