use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ContentClass {
#[default]
Ok,
ChromeError,
HttpErrorPage,
EmptyDocument,
TransportFail,
AppFail,
}
impl ContentClass {
pub fn is_evidence(self) -> bool {
matches!(self, ContentClass::Ok)
}
pub fn is_failure(self) -> bool {
!self.is_evidence()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StructuredBrowserResult {
pub ok: bool,
pub tool: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default)]
pub is_chrome_error: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default)]
pub content_class: ContentClass,
}
impl StructuredBrowserResult {
pub fn navigate_ok(final_url: &str, title: &str) -> Self {
let is_chrome_error = url_is_chrome_error(final_url);
let content_class = classify_browser_content(
Some(final_url),
Some(title),
None,
is_chrome_error,
None,
true,
);
Self {
ok: content_class.is_evidence(),
tool: "browser_navigate".into(),
final_url: Some(final_url.to_string()),
title: Some(title.to_string()),
error_text: None,
is_chrome_error,
node_count: None,
body: None,
content_class,
}
}
pub fn navigate_err(url: &str, error_text: &str) -> Self {
let is_chrome_error = url_is_chrome_error(url);
let content_class = if is_chrome_error {
ContentClass::ChromeError
} else {
ContentClass::TransportFail
};
Self {
ok: false,
tool: "browser_navigate".into(),
final_url: Some(url.to_string()),
title: None,
error_text: Some(error_text.to_string()),
is_chrome_error,
node_count: None,
body: None,
content_class,
}
}
pub fn snapshot_ok(final_url: &str, body: &str, node_count: Option<u32>) -> Self {
let is_chrome_error = url_is_chrome_error(final_url);
let content_class = classify_browser_content(
Some(final_url),
None,
Some(body),
is_chrome_error,
node_count,
true,
);
Self {
ok: content_class.is_evidence(),
tool: "browser_snapshot".into(),
final_url: Some(final_url.to_string()),
title: None,
error_text: None,
is_chrome_error,
node_count,
body: Some(body.to_string()),
content_class,
}
}
pub fn vision_ok(final_url: &str, analysis: &str, document_ready: bool) -> Self {
let is_chrome_error = url_is_chrome_error(final_url);
let transport_ok = document_ready && !final_url.is_empty();
let content_class = classify_browser_content(
Some(final_url),
None,
Some(analysis),
is_chrome_error,
None,
transport_ok,
);
Self {
ok: content_class.is_evidence(),
tool: "browser_vision".into(),
final_url: Some(final_url.to_string()),
title: None,
error_text: None,
is_chrome_error,
node_count: None,
body: Some(analysis.to_string()),
content_class,
}
}
pub fn reclassify(&mut self) {
let transport_ok = self.error_text.is_none();
self.content_class = classify_browser_content(
self.final_url.as_deref(),
self.title.as_deref(),
self.body.as_deref(),
self.is_chrome_error,
self.node_count,
transport_ok,
);
if self.tool == "browser_snapshot"
&& self.content_class == ContentClass::Ok
&& self.node_count.unwrap_or(0) == 0
{
self.content_class = ContentClass::EmptyDocument;
}
self.ok = self.content_class.is_evidence();
}
pub fn to_tool_result_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
json!({
"ok": self.ok,
"tool": self.tool,
"is_chrome_error": self.is_chrome_error,
"content_class": self.content_class,
})
.to_string()
})
}
pub fn to_tool_result_text(&self) -> String {
let mut out = self.to_tool_result_json();
if let Some(body) = &self.body
&& !body.is_empty()
{
out.push('\n');
out.push_str(body);
} else if self.tool == "browser_navigate"
&& self.ok
&& let (Some(url), Some(title)) = (&self.final_url, &self.title)
{
out.push_str(&format!("\nNavigated to: {url}\nTitle: {title}"));
}
out
}
}
pub fn classify_browser_content(
url: Option<&str>,
title: Option<&str>,
body: Option<&str>,
is_chrome_error: bool,
node_count: Option<u32>,
transport_ok: bool,
) -> ContentClass {
if is_chrome_error || url.map(url_is_chrome_error).unwrap_or(false) {
return ContentClass::ChromeError;
}
if !transport_ok {
return ContentClass::TransportFail;
}
if let Some(t) = title {
let tl = t.trim().to_ascii_lowercase();
if matches!(
tl.as_str(),
"error response"
| "404 not found"
| "403 forbidden"
| "500 internal server error"
| "not found"
) {
return ContentClass::HttpErrorPage;
}
}
if let Some(b) = body {
let head: String = b.chars().take(200).collect();
if head.contains("Error response") && head.contains("Error code:") {
return ContentClass::HttpErrorPage;
}
if head.contains("\"detail\":\"Not Found\"") || head.contains("\"detail\": \"Not Found\"") {
return ContentClass::HttpErrorPage;
}
if app_fail_marker_present(b) {
return ContentClass::AppFail;
}
}
if let Some(t) = title
&& app_fail_marker_present(t)
{
return ContentClass::AppFail;
}
if let Some(0) = node_count {
return ContentClass::EmptyDocument;
}
ContentClass::Ok
}
fn app_fail_marker_present(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
const MARKERS: &[&str] = &[
"3d failed to load",
"webgl failed",
"failed to initialize webgl",
"three.js failed",
];
MARKERS.iter().any(|m| lower.contains(m))
}
pub fn url_is_chrome_error(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
lower.contains("chrome-error://") || lower.starts_with("chrome://error")
}
pub fn parse_structured_browser_result(tool_result: &str) -> Option<StructuredBrowserResult> {
let trimmed = tool_result.trim();
if let Ok(mut v) = serde_json::from_str::<StructuredBrowserResult>(trimmed)
&& !v.tool.is_empty()
{
v.is_chrome_error = v.is_chrome_error
|| v.final_url
.as_deref()
.map(url_is_chrome_error)
.unwrap_or(false);
v.reclassify();
return Some(v);
}
let first_line = trimmed.lines().next()?.trim();
if first_line.starts_with('{')
&& let Ok(mut v) = serde_json::from_str::<StructuredBrowserResult>(first_line)
{
if v.body.is_none() {
let rest = trimmed[first_line.len()..].trim();
if !rest.is_empty() {
v.body = Some(rest.to_string());
}
}
v.is_chrome_error = v.is_chrome_error
|| v.final_url
.as_deref()
.map(url_is_chrome_error)
.unwrap_or(false);
v.reclassify();
return Some(v);
}
None
}
pub fn structured_browser_nav_succeeded(tool_result: &str) -> Option<bool> {
let parsed = parse_structured_browser_result(tool_result)?;
if !matches!(
parsed.tool.as_str(),
"browser_navigate" | "browser_snapshot" | "browser_vision"
) {
return None;
}
Some(parsed.ok && parsed.content_class.is_evidence())
}
pub fn browser_content_class(tool_result: &str) -> Option<ContentClass> {
parse_structured_browser_result(tool_result).map(|p| p.content_class)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structured_browser_navigate_roundtrip() {
let r = StructuredBrowserResult::navigate_ok("http://127.0.0.1:8000/", "3D Chess");
let text = r.to_tool_result_text();
let parsed = parse_structured_browser_result(&text).expect("parse");
assert!(parsed.ok);
assert!(!parsed.is_chrome_error);
assert_eq!(parsed.content_class, ContentClass::Ok);
assert_eq!(parsed.final_url.as_deref(), Some("http://127.0.0.1:8000/"));
}
#[test]
fn structured_browser_chrome_error_not_ok() {
let r = StructuredBrowserResult::navigate_ok("chrome-error://chromewebdata/", "Error");
assert!(!r.ok);
assert!(r.is_chrome_error);
assert_eq!(r.content_class, ContentClass::ChromeError);
assert_eq!(
structured_browser_nav_succeeded(&r.to_tool_result_json()),
Some(false)
);
}
#[test]
fn nf_u1_error_response_title_is_http_error_page() {
let r = StructuredBrowserResult::navigate_ok("http://127.0.0.1:8000/", "Error response");
assert!(!r.ok);
assert_eq!(r.content_class, ContentClass::HttpErrorPage);
assert_eq!(
structured_browser_nav_succeeded(&r.to_tool_result_json()),
Some(false)
);
}
#[test]
fn structured_browser_snapshot_requires_nodes_not_prose() {
let thin = StructuredBrowserResult::snapshot_ok(
"http://127.0.0.1:8000/",
"This page isn't working — ERR_EMPTY_RESPONSE",
Some(0),
);
assert!(!thin.ok);
assert!(!thin.is_chrome_error);
assert_eq!(thin.content_class, ContentClass::EmptyDocument);
let chrome_url = StructuredBrowserResult::snapshot_ok(
"chrome-error://chromewebdata/",
"Board with pieces",
Some(12),
);
assert!(!chrome_url.ok);
assert!(chrome_url.is_chrome_error);
let good = StructuredBrowserResult::snapshot_ok(
"http://127.0.0.1:8000/",
"Board with pieces @e1 @e2",
Some(12),
);
assert!(good.ok);
assert_eq!(good.content_class, ContentClass::Ok);
}
#[test]
fn structured_browser_vision_requires_document_ready() {
let not_ready = StructuredBrowserResult::vision_ok(
"http://127.0.0.1:8000/",
"fullscreen near-black loading spinner, no game",
false,
);
assert!(!not_ready.ok);
assert_eq!(not_ready.content_class, ContentClass::TransportFail);
let ready = StructuredBrowserResult::vision_ok(
"http://127.0.0.1:8000/",
"fullscreen near-black loading spinner, no game",
true,
);
assert!(ready.ok);
assert!(!ready.is_chrome_error);
let bad = StructuredBrowserResult::vision_ok(
"chrome-error://chromewebdata/",
"looks like a beautiful chess UI",
true,
);
assert!(!bad.ok);
assert!(bad.is_chrome_error);
}
#[test]
fn reclassify_legacy_json_without_content_class_field() {
let raw = r#"{"ok":true,"tool":"browser_navigate","final_url":"http://127.0.0.1:8000/","title":"Error response","is_chrome_error":false}"#;
let parsed = parse_structured_browser_result(raw).expect("parse");
assert_eq!(parsed.content_class, ContentClass::HttpErrorPage);
assert!(!parsed.ok);
}
#[test]
fn app_fail_marker_3d_failed_to_load() {
let body = r#"- Page: ♔ 3D Chess
heading "3D failed to load"
button "Reload""#;
let r = StructuredBrowserResult::snapshot_ok("http://127.0.0.1:8000/", body, Some(9));
assert_eq!(r.content_class, ContentClass::AppFail);
assert!(!r.ok);
}
#[test]
fn api_not_found_json_is_http_error_page() {
let body = r#"body pre: {"detail":"Not Found"}"#;
let class = classify_browser_content(
Some("http://127.0.0.1:8765/"),
Some(""),
Some(body),
false,
Some(0),
true,
);
let class2 = classify_browser_content(
Some("http://127.0.0.1:8765/"),
Some(""),
Some(body),
false,
None,
true,
);
assert_eq!(class2, ContentClass::HttpErrorPage);
let _ = class;
}
}