Skip to main content

heartbit_core/browser/
bench.rs

1//! Live browser-agent benchmark — the "go to a real site and perform actions"
2//! smoke test, as a repeatable evaluation process.
3//!
4//! The unit tests prove navigation works; this proves the *agent loop* works
5//! against live, uncontrolled web pages: a real LLM (Kimi K2 via OpenRouter)
6//! drives real Chrome through the full [`BrowserAgentBuilder`] stack to complete
7//! multi-step interactive tasks (log in, wait for async content, navigate +
8//! extract), and each outcome is graded by an INDEPENDENT deterministic oracle
9//! — we re-snapshot the real page after the run and check a verbatim ground-truth
10//! signal, rather than trusting the agent's own "done" claim (the
11//! Online-Mind2Web / "Illusion of Progress" lesson: agents over-report success).
12//!
13//! Design mirrors the rest of the module: the gradable core ([`Oracle::grade`],
14//! [`scorecard`]) is pure and unit-tested; the live driver ([`run_bench`]) and
15//! the `#[ignore]` live suite are the thin shells over real Chrome + a real model.
16
17use std::sync::{Arc, Mutex};
18use std::time::Instant;
19
20use crate::agent::events::AgentEvent;
21use crate::execution_context::ExecutionContext;
22use crate::llm::LlmProvider;
23use crate::tool::Tool;
24
25use super::builder::BrowserAgentBuilder;
26
27/// State a bench run's event callback writes; read after the run on BOTH paths so
28/// a max-turns FAILURE still reports its turn count + ordered tool sequence
29/// (which `AgentOutput`, lost on the error path, cannot provide). Field names
30/// match the real [`AgentEvent`] variants (verified against agent/events.rs).
31#[derive(Default)]
32struct RunTrace {
33    turns: usize,
34    tool_calls: usize,
35    tools: Vec<String>,
36    input_tokens: u32,
37    output_tokens: u32,
38}
39
40/// A deterministic success oracle, graded against the REAL post-run page and the
41/// agent's answer — independent of whatever the agent claims it did.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Oracle {
44    /// The final-page accessibility snapshot must contain this verbatim substring
45    /// (e.g. a success banner that only renders once the task is complete).
46    FinalPageContains(String),
47    /// The agent's text answer must contain this substring (case-insensitive) —
48    /// for extraction tasks where the proof is the value reported, not page state.
49    AgentAnswerContains(String),
50    /// The final page URL must contain this fragment (e.g. `/secure` after login).
51    UrlContains(String),
52}
53
54impl Oracle {
55    /// Grade an outcome. `snapshot` is an independent post-run `take_snapshot` of
56    /// the live page; `answer` is the agent's final text. Pure + unit-testable.
57    pub fn grade(&self, snapshot: &str, answer: &str) -> bool {
58        match self {
59            Oracle::FinalPageContains(s) => snapshot.contains(s.as_str()),
60            Oracle::AgentAnswerContains(s) => answer.to_lowercase().contains(&s.to_lowercase()),
61            Oracle::UrlContains(s) => {
62                snapshot_url(snapshot).is_some_and(|u| u.contains(s.as_str()))
63            }
64        }
65    }
66}
67
68/// Extract the `RootWebArea` `url="..."` value from a snapshot, if present.
69fn snapshot_url(snapshot: &str) -> Option<&str> {
70    let line = snapshot.lines().find(|l| l.contains("RootWebArea"))?;
71    let key = "url=\"";
72    let start = line.find(key)? + key.len();
73    let rest = &line[start..];
74    let end = rest.find('"')?;
75    Some(&rest[..end])
76}
77
78/// One benchmark task: a natural-language goal for the agent + a deterministic
79/// oracle for grading.
80#[derive(Debug, Clone)]
81pub struct BenchTask {
82    /// Short identifier.
83    pub name: String,
84    /// Difficulty label (easy/medium/hard/hardest) for the scorecard.
85    pub difficulty: String,
86    /// Hosts the agent is allowed to navigate to (deny-by-default otherwise).
87    pub allow_hosts: Vec<String>,
88    /// The task handed to the browser agent.
89    pub instruction: String,
90    /// Deterministic success check.
91    pub oracle: Oracle,
92    /// Turn cap for this task's ReAct loop.
93    pub max_turns: usize,
94    /// Tools the agent is allowed (token control). Empty = all preset tools.
95    pub tools: Vec<String>,
96}
97
98/// Outcome of running one [`BenchTask`].
99#[derive(Debug, Clone, Default)]
100pub struct BenchResult {
101    /// Task name.
102    pub name: String,
103    /// Difficulty label.
104    pub difficulty: String,
105    /// Whether the independent oracle judged the task complete.
106    pub passed: bool,
107    /// Tool calls the agent made.
108    pub tool_calls: usize,
109    /// Input tokens consumed.
110    pub input_tokens: u32,
111    /// Output tokens produced.
112    pub output_tokens: u32,
113    /// Estimated cost in USD, if the provider reported it.
114    pub cost_usd: Option<f64>,
115    /// Wall-clock duration in milliseconds.
116    pub millis: u128,
117    /// Number of attempts made (1 = passed first try). Transient per-task
118    /// failures (max-turns dither, a malformed model reply) are retried up to the
119    /// suite's `max_attempts`; this records how many it actually took. `pass@k`.
120    pub attempts: usize,
121    /// LLM turns observed via events — populated even when the task FAILS on a
122    /// max-turns loop (unlike `AgentOutput`, lost on the error path).
123    pub turns: usize,
124    /// Ordered tool-call sequence the agent issued (e.g. `["navigate_page",
125    /// "take_snapshot", "click", ...]`). Captured via events, so a FAILED task
126    /// still shows what it looped on — the diagnostic the error path discarded.
127    pub trace: Vec<String>,
128    /// First ~160 chars of the agent's answer (for the trace).
129    pub answer_excerpt: String,
130    /// The agent's FULL final answer (for an LLM judge / inspection).
131    pub answer: String,
132    /// The independent post-run page snapshot used for grading (for an LLM judge).
133    pub final_snapshot: String,
134    /// Error, if the build or run failed before grading.
135    pub error: Option<String>,
136}
137
138/// Run a benchmark suite live with ONE attempt per task. Equivalent to
139/// [`run_bench_with_retries`] with `max_attempts = 1`.
140pub async fn run_bench<P: LlmProvider>(
141    provider: Arc<P>,
142    tools: Vec<Arc<dyn Tool>>,
143    tasks: &[BenchTask],
144) -> Vec<BenchResult> {
145    run_bench_with_retries(provider, tools, tasks, 1).await
146}
147
148/// Run a benchmark suite live, retrying each task up to `max_attempts` times
149/// until its oracle passes (pass@k). The residual failures on a capable model are
150/// TRANSIENT per-task variance — a max-turns dither on one run, a malformed reply
151/// on another, migrating between tasks — so a bounded retry is the
152/// production-honest way to turn a flaky-but-capable agent into a reliable one.
153/// Each attempt is a FRESH agent (fresh context + turn budget); grading is
154/// unchanged (independent post-run snapshot). The returned [`BenchResult`]
155/// reflects the last attempt and records [`BenchResult::attempts`].
156pub async fn run_bench_with_retries<P: LlmProvider>(
157    provider: Arc<P>,
158    tools: Vec<Arc<dyn Tool>>,
159    tasks: &[BenchTask],
160    max_attempts: usize,
161) -> Vec<BenchResult> {
162    let ctx = ExecutionContext::default();
163    let snapshot_tool = tools
164        .iter()
165        .find(|t| t.definition().name == "take_snapshot")
166        .cloned();
167    let cap = max_attempts.max(1);
168
169    let mut results = Vec::with_capacity(tasks.len());
170    for task in tasks {
171        let mut r = BenchResult::default();
172        for attempt in 1..=cap {
173            r = run_task_once(&provider, &tools, snapshot_tool.as_ref(), &ctx, task).await;
174            r.attempts = attempt;
175            if r.passed {
176                break;
177            }
178        }
179        results.push(r);
180    }
181    results
182}
183
184/// Run a single task once and grade it. The per-attempt unit used by
185/// [`run_bench_with_retries`]. Never panics — build/run errors land in
186/// [`BenchResult::error`] and grade as failed.
187async fn run_task_once<P: LlmProvider>(
188    provider: &Arc<P>,
189    tools: &[Arc<dyn Tool>],
190    snapshot_tool: Option<&Arc<dyn Tool>>,
191    ctx: &ExecutionContext,
192    task: &BenchTask,
193) -> BenchResult {
194    let started = Instant::now();
195    let mut r = BenchResult {
196        name: task.name.clone(),
197        difficulty: task.difficulty.clone(),
198        ..BenchResult::default()
199    };
200    // The agent's final text answer, if the run returned Ok. Empty on a run error
201    // (e.g. max-turns) — page-state oracles still grade from the live page.
202    let mut answer = String::new();
203
204    // Shared trace the event callback writes; read on BOTH paths so a max-turns
205    // FAILURE still reports its turns + ordered tool sequence.
206    let trace = Arc::new(Mutex::new(RunTrace::default()));
207    let trace_cb = Arc::clone(&trace);
208    let on_event: Arc<crate::agent::events::OnEvent> = Arc::new(move |ev: AgentEvent| {
209        let Ok(mut t) = trace_cb.lock() else { return };
210        match ev {
211            AgentEvent::TurnStarted { turn, .. } => t.turns = t.turns.max(turn),
212            AgentEvent::ToolCallStarted { tool_name, .. } => {
213                t.tool_calls += 1;
214                t.tools.push(tool_name);
215            }
216            AgentEvent::RunCompleted { total_usage, .. } => {
217                t.input_tokens = total_usage.input_tokens;
218                t.output_tokens = total_usage.output_tokens;
219            }
220            _ => {}
221        }
222    });
223
224    // Bound O(n^2) history growth on long multi-page runs: prune OLD snapshots to
225    // head+tail, keep task + recent 3 results full. Safe for the extraction task
226    // (the reported value is in a recent, preserved snapshot).
227    let prune = crate::agent::pruner::SessionPruneConfig {
228        keep_recent_n: 3,
229        pruned_tool_result_max_bytes: 256,
230        preserve_task: true,
231    };
232    match BrowserAgentBuilder::new(Arc::clone(provider))
233        .name(task.name.clone())
234        .allow_hosts(task.allow_hosts.clone())
235        .max_turns(task.max_turns)
236        .tools_allow(task.tools.clone())
237        .on_event(on_event)
238        .session_prune(prune)
239        // After 3 identical consecutive tool batches (the re-snapshot /
240        // re-wait_for dither), inject a stop-and-finish warning and continue.
241        .max_identical_tool_calls(3)
242        .build_with_tools(tools.to_vec())
243    {
244        Err(e) => r.error = Some(format!("build: {e}")),
245        Ok(agent) => match agent.execute(&task.instruction).await {
246            Err(e) => r.error = Some(format!("run: {e}")),
247            Ok(out) => {
248                r.cost_usd = out.estimated_cost_usd;
249                r.answer_excerpt = out.result.chars().take(160).collect();
250                answer = out.result;
251            }
252        },
253    }
254
255    // Grade against the LIVE page even when the agent run errored (e.g. it
256    // reached the goal state but looped past max_turns without cleanly
257    // reporting). For a page-state oracle (FinalPageContains / UrlContains) the
258    // page — not the agent's clean termination — is ground truth; discarding a
259    // reached goal-state just because the loop didn't stop was a grading bug. An
260    // AgentAnswerContains oracle still needs the answer, so it stays failed on
261    // the error path (no answer). The run error remains recorded in `r.error`
262    // for full transparency. Skipped only when the BUILD failed (no browser
263    // state to grade).
264    let build_failed = matches!(&r.error, Some(e) if e.starts_with("build:"));
265    if !build_failed {
266        let snap = match snapshot_tool {
267            Some(t) => t
268                .execute(ctx, serde_json::json!({}))
269                .await
270                .map(|o| o.content)
271                .unwrap_or_default(),
272            None => String::new(),
273        };
274        r.passed = task.oracle.grade(&snap, &answer);
275        r.final_snapshot = snap;
276    }
277    r.answer = answer;
278    // Fold in the captured trace — the ONLY source of turns/tools on the failure
279    // path, and a cross-check on success.
280    if let Ok(t) = trace.lock() {
281        r.turns = t.turns;
282        r.tool_calls = t.tool_calls;
283        r.trace = t.tools.clone();
284        r.input_tokens = t.input_tokens;
285        r.output_tokens = t.output_tokens;
286    }
287    r.millis = started.elapsed().as_millis();
288    // Derive cost from the static price table using the FINAL token counts folded
289    // in above. OpenRouter reports no per-call cost (out.estimated_cost_usd is
290    // None), so the price table is the only source — and it must run AFTER the
291    // trace fold, when input/output_tokens are known. Computed on BOTH paths: an
292    // errored (e.g. max-turns) run still burned tokens. A provider-reported cost
293    // (set on the Ok path) survives as the fallback for models absent from the
294    // table.
295    if let Some((pin, pout)) = provider.model_name().and_then(model_price_per_mtok) {
296        r.cost_usd = Some(estimate_cost_usd(
297            r.input_tokens,
298            r.output_tokens,
299            pin,
300            pout,
301        ));
302    }
303    r
304}
305
306/// USD cost of a run from token counts and per-million-token prices. Pure.
307pub fn estimate_cost_usd(
308    input_tokens: u32,
309    output_tokens: u32,
310    in_per_mtok: f64,
311    out_per_mtok: f64,
312) -> f64 {
313    (input_tokens as f64 / 1_000_000.0) * in_per_mtok
314        + (output_tokens as f64 / 1_000_000.0) * out_per_mtok
315}
316
317/// Per-million-token (input, output) USD price for a model, or `None` if unknown.
318/// Snapshot of OpenRouter list prices verified 2026-05-31 (copied from the live
319/// /models API, not estimated). Prices drift and vary by sub-provider/region, so
320/// the cost column is an estimate; re-pull for exact billing. Unknown models get
321/// no cost (column shows `-`).
322pub fn model_price_per_mtok(model: &str) -> Option<(f64, f64)> {
323    match model {
324        "deepseek/deepseek-v3.2" => Some((0.2520, 0.3780)),
325        "moonshotai/kimi-k2-0905" => Some((0.6000, 2.5000)),
326        "z-ai/glm-4.6" => Some((0.4286, 1.7143)),
327        "qwen/qwen3-235b-a22b-2507" => Some((0.0710, 0.1000)),
328        "qwen/qwen3-235b-a22b-thinking-2507" => Some((0.0780, 0.3600)),
329        "minimax/minimax-m2" => Some((0.2600, 1.0000)),
330        "google/gemini-2.5-pro" => Some((1.2500, 10.0000)),
331        "google/gemini-3.1-pro-preview" => Some((2.0000, 12.0000)),
332        "x-ai/grok-4.3" => Some((1.2500, 2.5000)),
333        "x-ai/grok-4.20" => Some((1.2500, 2.5000)),
334        "openai/gpt-5.1" => Some((1.2500, 10.0000)),
335        "openai/gpt-4.1" => Some((2.0000, 8.0000)),
336        "anthropic/claude-opus-4.8" => Some((5.0000, 25.0000)),
337        _ => None,
338    }
339}
340
341/// Render a human-readable scorecard from results.
342pub fn scorecard(results: &[BenchResult]) -> String {
343    use std::fmt::Write as _;
344    let passed = results.iter().filter(|r| r.passed).count();
345    let total_cost: f64 = results.iter().filter_map(|r| r.cost_usd).sum();
346    let mut s = String::new();
347    let _ = writeln!(
348        s,
349        "=== Browser-agent benchmark: {}/{} tasks passed (est. ${:.5} total) ===",
350        passed,
351        results.len(),
352        total_cost
353    );
354    let _ = writeln!(
355        s,
356        "{:<24} {:<8} {:<5} {:>4} {:>6} {:>8} {:>8} {:>10} {:>9}",
357        "task", "diff", "pass", "att", "calls", "in_tok", "out_tok", "cost$", "ms"
358    );
359    for r in results {
360        let cost = match r.cost_usd {
361            Some(c) => format!("{c:.5}"),
362            None => "-".to_string(),
363        };
364        let _ = writeln!(
365            s,
366            "{:<24} {:<8} {:<5} {:>4} {:>6} {:>8} {:>8} {:>10} {:>9}",
367            r.name,
368            r.difficulty,
369            if r.passed { "PASS" } else { "FAIL" },
370            r.attempts,
371            r.tool_calls,
372            r.input_tokens,
373            r.output_tokens,
374            cost,
375            r.millis
376        );
377        if let Some(e) = &r.error {
378            let _ = writeln!(s, "    ! {e}");
379        }
380        // The tool trace is the diagnostic for max-turns loops — show it on any
381        // task that captured one; on success it documents the path taken.
382        if !r.trace.is_empty() {
383            let _ = writeln!(s, "    trace: {}", r.trace.join(" -> "));
384        }
385        if !r.answer_excerpt.is_empty() {
386            let _ = writeln!(s, "    answer: {}", r.answer_excerpt.replace('\n', " "));
387        }
388    }
389    s
390}
391
392/// The default tiered benchmark suite: a sanity task plus three genuinely
393/// multi-step interactive tasks — form login, async wait-for-stability, and
394/// multi-page navigation + extraction. Every oracle value was verified verbatim
395/// against the live sites (2026-05-31 recon), so a pass means the agent actually
396/// achieved the goal, not that it claimed to.
397pub fn bench_suite() -> Vec<BenchTask> {
398    vec![
399        // Sanity: navigate + read. Confirms the harness is wired end-to-end.
400        BenchTask {
401            name: "example_extract".into(),
402            difficulty: "easy".into(),
403            allow_hosts: vec!["example.com".into()],
404            instruction: "Go to https://example.com and report the main heading text shown on \
405                           the page."
406                .into(),
407            oracle: Oracle::FinalPageContains("Example Domain".into()),
408            max_turns: 8,
409            tools: vec!["navigate_page".into(), "take_snapshot".into()],
410        },
411        // Form auth: fill two fields, submit, verify the state transition to the
412        // secure area (the success banner only renders after a correct login).
413        BenchTask {
414            name: "the_internet_login".into(),
415            difficulty: "medium".into(),
416            allow_hosts: vec!["the-internet.herokuapp.com".into()],
417            instruction: "Go to https://the-internet.herokuapp.com/login and log in with \
418                           username \"tomsmith\" and password \"SuperSecretPassword!\". Confirm \
419                           you reached the secure area."
420                .into(),
421            oracle: Oracle::FinalPageContains("You logged into a secure area!".into()),
422            max_turns: 22,
423            tools: vec![
424                "navigate_page".into(),
425                "take_snapshot".into(),
426                "fill".into(),
427                "fill_form".into(),
428                "click".into(),
429            ],
430        },
431        // Async settle: click Start, wait out a 5s spinner, read the revealed text
432        // (not in the initial DOM) — exercises the wait-for-stability discipline.
433        BenchTask {
434            name: "dynamic_loading_settle".into(),
435            difficulty: "hard".into(),
436            allow_hosts: vec!["the-internet.herokuapp.com".into()],
437            instruction: "Go to https://the-internet.herokuapp.com/dynamic_loading/2 , click the \
438                           Start button, wait for the content to finish loading, and report the \
439                           text that appears."
440                .into(),
441            oracle: Oracle::FinalPageContains("Hello World!".into()),
442            max_turns: 18,
443            tools: vec![
444                "navigate_page".into(),
445                "take_snapshot".into(),
446                "click".into(),
447                "wait_for".into(),
448            ],
449        },
450        // Multi-step navigation + extraction across pages: home -> Travel category
451        // -> the specific book -> read its price. Graded on the reported value.
452        BenchTask {
453            name: "books_travel_price".into(),
454            difficulty: "hardest".into(),
455            allow_hosts: vec!["books.toscrape.com".into()],
456            instruction: "Go to https://books.toscrape.com , open the Travel category, find the \
457                           book titled \"Under the Tuscan Sun\", open its page, and report its \
458                           exact price."
459                .into(),
460            oracle: Oracle::AgentAnswerContains("37.33".into()),
461            max_turns: 24,
462            tools: vec![
463                "navigate_page".into(),
464                "take_snapshot".into(),
465                "click".into(),
466            ],
467        },
468    ]
469}
470
471/// A HARDER suite for stress-testing a strong model (qwen3-235b is the default).
472/// Each task needs genuine multi-step navigation + cross-element reasoning, not a
473/// single-value read. All oracle values were verified verbatim via `curl` of the
474/// live pages (2026-05-31), so they are deterministic ground truth.
475pub fn hard_suite() -> Vec<BenchTask> {
476    let books = || {
477        vec![
478            "navigate_page".to_string(),
479            "take_snapshot".to_string(),
480            "click".to_string(),
481        ]
482    };
483    vec![
484        // Count: navigate to a specific category, read the "N results" heading.
485        BenchTask {
486            name: "books_mystery_count".into(),
487            difficulty: "hard".into(),
488            allow_hosts: vec!["books.toscrape.com".into()],
489            instruction: "Go to https://books.toscrape.com , open the Mystery category, and \
490                           report exactly how many books are in it (the number of results)."
491                .into(),
492            oracle: Oracle::AgentAnswerContains("32".into()),
493            max_turns: 20,
494            tools: books(),
495        },
496        // Cross-item reasoning: read ALL Travel books' prices, pick the max.
497        BenchTask {
498            name: "books_travel_priciest".into(),
499            difficulty: "hardest".into(),
500            allow_hosts: vec!["books.toscrape.com".into()],
501            instruction: "Go to https://books.toscrape.com , open the Travel category, compare \
502                           the prices of every book in it, and report the TITLE of the most \
503                           expensive one."
504                .into(),
505            oracle: Oracle::AgentAnswerContains("The Great Railway Bazaar".into()),
506            max_turns: 26,
507            tools: books(),
508        },
509        // Whole-catalog figure shown on the home page.
510        BenchTask {
511            name: "books_catalog_total".into(),
512            difficulty: "hard".into(),
513            allow_hosts: vec!["books.toscrape.com".into()],
514            instruction: "Go to https://books.toscrape.com and report the total number of books \
515                           listed in the whole catalogue."
516                .into(),
517            oracle: Oracle::AgentAnswerContains("1000".into()),
518            max_turns: 16,
519            tools: books(),
520        },
521    ]
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::error::Error;
528    use crate::llm::types::{
529        CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
530    };
531
532    const LOGIN_OK_SNAP: &str = r#"uid=1_0 RootWebArea "The Internet" url="https://the-internet.herokuapp.com/secure"
533  uid=1_1 StaticText "You logged into a secure area!"
534  uid=1_2 button "Logout""#;
535
536    const LOGIN_FAIL_SNAP: &str = r#"uid=1_0 RootWebArea "The Internet" url="https://the-internet.herokuapp.com/login"
537  uid=1_1 StaticText "Your username is invalid!"
538  uid=1_2 textbox "Username""#;
539
540    #[test]
541    fn final_page_contains_grades_on_banner() {
542        let o = Oracle::FinalPageContains("You logged into a secure area!".into());
543        assert!(o.grade(LOGIN_OK_SNAP, "I logged in."));
544        assert!(!o.grade(LOGIN_FAIL_SNAP, "I logged in.")); // agent claims success, page says no
545    }
546
547    #[test]
548    fn url_contains_grades_on_redirect() {
549        let o = Oracle::UrlContains("/secure".into());
550        assert!(o.grade(LOGIN_OK_SNAP, ""));
551        assert!(!o.grade(LOGIN_FAIL_SNAP, ""));
552    }
553
554    #[test]
555    fn agent_answer_contains_is_case_insensitive() {
556        let o = Oracle::AgentAnswerContains("£51.77".into());
557        assert!(o.grade("", "The price is £51.77 incl. tax."));
558        assert!(!o.grade("", "I could not find the price."));
559        let o2 = Oracle::AgentAnswerContains("Hello World".into());
560        assert!(o2.grade("", "the revealed text was HELLO WORLD!"));
561    }
562
563    #[test]
564    fn oracle_does_not_trust_agent_over_page() {
565        // The whole point: a lying "done" answer must still FAIL when the page
566        // doesn't show the success signal.
567        let o = Oracle::FinalPageContains("secure area".into());
568        assert!(
569            !o.grade(LOGIN_FAIL_SNAP, "Done! Successfully logged in."),
570            "page is the source of truth, not the agent's claim"
571        );
572    }
573
574    #[test]
575    fn snapshot_url_extracts_root_url() {
576        assert_eq!(
577            snapshot_url(LOGIN_OK_SNAP),
578            Some("https://the-internet.herokuapp.com/secure")
579        );
580        assert_eq!(snapshot_url("uid=1_0 RootWebArea \"x\""), None); // no url=
581        assert_eq!(snapshot_url(""), None);
582    }
583
584    #[test]
585    fn bench_suite_is_wellformed() {
586        let suite = bench_suite();
587        assert_eq!(suite.len(), 4, "tiered suite has 4 tasks");
588        for t in &suite {
589            assert!(!t.name.is_empty());
590            assert!(
591                !t.allow_hosts.is_empty(),
592                "{} must allowlist a host",
593                t.name
594            );
595            assert!(
596                t.instruction.contains("http"),
597                "{} instruction must name a URL",
598                t.name
599            );
600            assert!(t.max_turns >= 4, "{} max_turns too low", t.name);
601            // The task's allowlisted host should appear in its instruction URL.
602            assert!(
603                t.allow_hosts
604                    .iter()
605                    .any(|h| t.instruction.contains(h.as_str())),
606                "{} instruction must target its allowlisted host",
607                t.name
608            );
609        }
610        let diffs: Vec<_> = suite.iter().map(|t| t.difficulty.as_str()).collect();
611        assert!(
612            diffs.contains(&"easy") && diffs.contains(&"hardest"),
613            "suite must span easy..hardest, got {diffs:?}"
614        );
615    }
616
617    /// LIVE: Kimi K2 (OpenRouter) drives real Chrome through the whole benchmark
618    /// suite on real sites, each graded by an independent oracle (a fresh
619    /// post-run snapshot of the real page, not the agent's claim). This is the
620    /// "go to a website and perform actions" smoke test. Run:
621    ///
622    /// ```text
623    /// OPENROUTER_API_KEY=sk-or-... cargo test -p heartbit-core --lib \
624    ///   browser::bench::tests::live_kimi_browser_benchmark -- --ignored --nocapture
625    /// ```
626    /// Shared live-bench helpers: read the key, the optional task filter, the
627    /// attempt count, and connect Chrome once. Returns `(key, tools, suite,
628    /// attempts)` or panics with a clear message.
629    async fn live_setup() -> (String, Vec<Arc<dyn Tool>>, Vec<BenchTask>, usize) {
630        let key = std::env::var("LLM_API_KEY")
631            .or_else(|_| std::env::var("OPENROUTER_API_KEY"))
632            .expect("set LLM_API_KEY or OPENROUTER_API_KEY to run this live benchmark");
633        let chrome = "/usr/bin/google-chrome";
634        let extra: Vec<String> = if std::path::Path::new(chrome).exists() {
635            vec!["--executable-path".to_string(), chrome.to_string()]
636        } else {
637            Vec::new()
638        };
639        let tools = crate::connect_preset_with_args("chrome-devtools", &extra)
640            .await
641            .expect("connect chrome-devtools preset");
642        let mut suite = bench_suite();
643        if let Ok(only) = std::env::var("BENCH_ONLY") {
644            suite.retain(|t| t.name.contains(&only));
645            assert!(!suite.is_empty(), "BENCH_ONLY={only} matched no task");
646        }
647        let attempts = std::env::var("BENCH_ATTEMPTS")
648            .ok()
649            .and_then(|v| v.parse::<usize>().ok())
650            .unwrap_or(3);
651        (key, tools, suite, attempts)
652    }
653
654    #[tokio::test]
655    #[ignore = "live: needs OpenRouter key + spawns real Chrome; hits 3 external sites"]
656    async fn live_kimi_browser_benchmark() {
657        let (key, tools, suite, attempts) = live_setup().await;
658        // Model override so this same test benches any OpenRouter model:
659        // BENCH_MODEL=z-ai/glm-4.6 cargo test ... -- --ignored --nocapture
660        // Default = qwen3-235b (the best value in the wide matrix: 4/4 at the
661        // lowest cost, ~$0.005/run). Override with BENCH_MODEL=<slug>.
662        let model = std::env::var("BENCH_MODEL")
663            .unwrap_or_else(|_| "qwen/qwen3-235b-a22b-2507".to_string());
664        let provider = Arc::new(crate::OpenRouterProvider::new(key, model));
665        let results = run_bench_with_retries(provider, tools, &suite, attempts).await;
666        eprintln!("\n{}", scorecard(&results));
667        if let Some(easy) = results.iter().find(|r| r.name == "example_extract") {
668            assert!(
669                easy.passed,
670                "harness sanity: the easy extract task must pass (see scorecard above)"
671            );
672        }
673    }
674
675    /// LIVE MODEL MATRIX: run the SAME benchmark suite (same tasks, same harness,
676    /// same pass@k) across several OpenRouter models and print a comparison table.
677    /// This is the fair apples-to-apples model comparison — only the model string
678    /// changes between rows. Default models are strong agentic/tool-calling ones
679    /// (Chinese-lab-first per project direction, no Anthropic); override with a
680    /// comma-separated `BENCH_MODELS`. Chrome is connected ONCE and shared.
681    ///
682    /// ```text
683    /// BENCH_MODELS="moonshotai/kimi-k2-0905,z-ai/glm-4.6,deepseek/deepseek-v3.2" \
684    ///   OPENROUTER_API_KEY=sk-or-... cargo test -p heartbit-core --lib \
685    ///   browser::bench::tests::live_model_matrix_benchmark -- --ignored --nocapture
686    /// ```
687    #[tokio::test]
688    #[ignore = "live: needs OpenRouter key + spawns real Chrome; runs the suite per model"]
689    async fn live_model_matrix_benchmark() {
690        let (key, tools, suite, attempts) = live_setup().await;
691        // Default field: DeepSeek-v3.2 first (the 4/4 leader from the first
692        // matrix), then strong agentic Chinese-lab models, then frontier models.
693        // Every slug was HTTP-probed (200, real completion) before listing —
694        // including OpenAI GPT-5.1 / GPT-4.1, which DO work here (an earlier
695        // "GPT 400/128k-context" claim was wrong: the 400 was a too-small
696        // max_tokens in the probe, not the model). grok-4.3-fast +
697        // gemini-3.1-pro-preview are the current x-ai/google frontier slugs (no
698        // grok-4.1-fast / gemini-3-pro exist on this route). Override with
699        // BENCH_MODELS="a,b,c".
700        let models: Vec<String> = std::env::var("BENCH_MODELS")
701            .unwrap_or_else(|_| {
702                "deepseek/deepseek-v3.2,moonshotai/kimi-k2-0905,z-ai/glm-4.6,\
703                 qwen/qwen3-235b-a22b-2507,minimax/minimax-m2,\
704                 google/gemini-2.5-pro,google/gemini-3.1-pro-preview,\
705                 x-ai/grok-4.3,x-ai/grok-4.20,\
706                 openai/gpt-5.1,openai/gpt-4.1"
707                    .to_string()
708            })
709            .split(',')
710            .map(|s| s.trim().to_string())
711            .filter(|s| !s.is_empty())
712            .collect();
713
714        let mut summary =
715            String::from("\n=== MODEL MATRIX (same tasks, same harness, pass@k) ===\n");
716        for model in &models {
717            let provider = Arc::new(crate::OpenRouterProvider::new(key.clone(), model.clone()));
718            let results =
719                run_bench_with_retries(Arc::clone(&provider), tools.clone(), &suite, attempts)
720                    .await;
721            let passed = results.iter().filter(|r| r.passed).count();
722            let in_tok: u32 = results.iter().map(|r| r.input_tokens).sum();
723            let out_tok: u32 = results.iter().map(|r| r.output_tokens).sum();
724            let per_task: Vec<String> = results
725                .iter()
726                .map(|r| {
727                    format!(
728                        "{}:{}",
729                        r.name.split('_').next().unwrap_or(&r.name),
730                        if r.passed { "P" } else { "F" }
731                    )
732                })
733                .collect();
734            eprintln!("\n--- {model} ---\n{}", scorecard(&results));
735            summary.push_str(&format!(
736                "{:<34} {}/{}  in={:>7} out={:>6}  [{}]\n",
737                model,
738                passed,
739                results.len(),
740                in_tok,
741                out_tok,
742                per_task.join(" ")
743            ));
744        }
745        eprintln!("{summary}");
746    }
747
748    /// Ask an LLM judge (Opus) whether the agent genuinely completed `task`,
749    /// given the final page snapshot + the agent's full answer. Reuses the
750    /// thin-DOM judge prompt + verdict parser from `super::super::judge`. Returns
751    /// (passed, reason, judge_input_tokens, judge_output_tokens). Fail-open toward
752    /// NOT-done (an unparseable/empty judge reply is treated as fail).
753    async fn opus_judge<P: crate::llm::LlmProvider>(
754        judge: &P,
755        task: &str,
756        snapshot: &str,
757        answer: &str,
758    ) -> (bool, String, u32, u32) {
759        use crate::browser::judge::{
760            CompletionVerdict, build_completion_prompt, parse_completion_verdict,
761        };
762        use crate::llm::types::{CompletionRequest, ContentBlock, Message};
763        let prompt = build_completion_prompt(task, &[], &[], snapshot);
764        // CompletionRequest has no Default — fill every field explicitly.
765        let req = CompletionRequest {
766            system: String::new(),
767            messages: vec![Message::user(format!(
768                "{prompt}\n\n# The agent's reported answer\n{answer}"
769            ))],
770            tools: Vec::new(),
771            max_tokens: 1024,
772            tool_choice: None,
773            reasoning_effort: None,
774        };
775        match judge.complete(req).await {
776            Err(e) => (false, format!("judge error: {e}"), 0, 0),
777            Ok(resp) => {
778                let text: String = resp
779                    .content
780                    .iter()
781                    .filter_map(|b| match b {
782                        ContentBlock::Text { text } => Some(text.as_str()),
783                        _ => None,
784                    })
785                    .collect();
786                let (pass, reason) = match parse_completion_verdict(&text) {
787                    Some(CompletionVerdict::Complete) => (true, "COMPLETE".to_string()),
788                    Some(CompletionVerdict::Incomplete(r)) => (false, format!("INCOMPLETE: {r}")),
789                    Some(CompletionVerdict::Uncertain(r)) => (false, format!("UNCERTAIN: {r}")),
790                    None => (false, "unparseable judge reply".to_string()),
791                };
792                (
793                    pass,
794                    reason,
795                    resp.usage.input_tokens,
796                    resp.usage.output_tokens,
797                )
798            }
799        }
800    }
801
802    /// LIVE: run the default model (qwen3-235b) on the HARDER suite, then have
803    /// Opus (claude-opus-4.8 via OpenRouter — judging only, not driving) grade
804    /// each result against the page + answer. Prints, per task: the deterministic
805    /// oracle verdict, the Opus verdict + reason, qwen run cost, and Opus judge
806    /// cost. The deterministic oracle is ground truth; the Opus judge is the
807    /// nuanced second opinion the user asked for on hard cases.
808    ///
809    /// ```text
810    /// BENCH_MODEL=qwen/qwen3-235b-a22b-2507 JUDGE_MODEL=anthropic/claude-opus-4.8 \
811    ///   OPENROUTER_API_KEY=sk-or-... cargo test -p heartbit-core --lib \
812    ///   browser::bench::tests::live_qwen_hard_opus_judge -- --ignored --nocapture
813    /// ```
814    #[tokio::test]
815    #[ignore = "live: needs OpenRouter key + Chrome; runs hard suite + Opus judge"]
816    async fn live_qwen_hard_opus_judge() {
817        let (key, tools, _suite, attempts) = live_setup().await;
818        let agent_model = std::env::var("BENCH_MODEL")
819            .unwrap_or_else(|_| "qwen/qwen3-235b-a22b-2507".to_string());
820        let judge_model = std::env::var("JUDGE_MODEL")
821            .unwrap_or_else(|_| "anthropic/claude-opus-4.8".to_string());
822
823        let provider = Arc::new(crate::OpenRouterProvider::new(
824            key.clone(),
825            agent_model.clone(),
826        ));
827        let judge = crate::OpenRouterProvider::new(key, judge_model.clone());
828
829        let suite = hard_suite();
830        let results = run_bench_with_retries(provider, tools, &suite, attempts).await;
831        eprintln!("\n=== HARD SUITE: {agent_model} (agent) judged by {judge_model} ===");
832        eprintln!("{}", scorecard(&results));
833
834        let (jp_in, jp_out) = model_price_per_mtok(&judge_model).unwrap_or((0.0, 0.0));
835        let mut judge_in = 0u32;
836        let mut judge_out = 0u32;
837        let mut both_pass = 0usize;
838        for r in &results {
839            let task = suite.iter().find(|t| t.name == r.name);
840            let instr = task.map(|t| t.instruction.as_str()).unwrap_or("");
841            let (jpass, reason, jin, jout) =
842                opus_judge(&judge, instr, &r.final_snapshot, &r.answer).await;
843            judge_in += jin;
844            judge_out += jout;
845            if r.passed && jpass {
846                both_pass += 1;
847            }
848            eprintln!(
849                "{:<24} oracle={} opus={} | {}",
850                r.name,
851                if r.passed { "PASS" } else { "FAIL" },
852                if jpass { "PASS" } else { "FAIL" },
853                reason.chars().take(120).collect::<String>()
854            );
855        }
856        let judge_cost = estimate_cost_usd(judge_in, judge_out, jp_in, jp_out);
857        eprintln!(
858            "\noracle+opus agree-PASS: {}/{}.  Opus judge tokens in={} out={} (~${:.5}).",
859            both_pass,
860            results.len(),
861            judge_in,
862            judge_out,
863            judge_cost
864        );
865    }
866
867    #[test]
868    fn hard_suite_is_wellformed() {
869        let s = hard_suite();
870        assert!(s.len() >= 3, "hard suite has >=3 tasks");
871        for t in &s {
872            assert!(t.instruction.contains("http"), "{} has a URL", t.name);
873            assert!(!t.tools.is_empty(), "{} pins tools", t.name);
874            assert!(t.max_turns >= 8, "{} has headroom", t.name);
875        }
876        // The verified-by-curl ground-truth oracle values are present.
877        let joined: String = s
878            .iter()
879            .map(|t| match &t.oracle {
880                Oracle::AgentAnswerContains(v) => v.clone(),
881                _ => String::new(),
882            })
883            .collect::<Vec<_>>()
884            .join("|");
885        assert!(
886            joined.contains("32")
887                && joined.contains("Great Railway Bazaar")
888                && joined.contains("1000")
889        );
890    }
891
892    #[test]
893    fn scorecard_counts_and_formats() {
894        let results = vec![
895            BenchResult {
896                name: "login".into(),
897                difficulty: "hard".into(),
898                passed: true,
899                tool_calls: 5,
900                input_tokens: 1200,
901                output_tokens: 80,
902                cost_usd: Some(0.01),
903                millis: 4200,
904                turns: 4,
905                attempts: 1,
906                trace: vec![
907                    "navigate_page".into(),
908                    "take_snapshot".into(),
909                    "fill_form".into(),
910                    "click".into(),
911                ],
912                answer_excerpt: "logged in".into(),
913                error: None,
914                ..Default::default()
915            },
916            BenchResult {
917                name: "basket".into(),
918                difficulty: "hardest".into(),
919                passed: false,
920                tool_calls: 9,
921                input_tokens: 3000,
922                output_tokens: 140,
923                cost_usd: None,
924                millis: 9000,
925                turns: 14,
926                attempts: 3,
927                trace: vec!["navigate_page".into(), "take_snapshot".into()],
928                answer_excerpt: String::new(),
929                error: Some("run: timeout".into()),
930                ..Default::default()
931            },
932        ];
933        let card = scorecard(&results);
934        assert!(card.contains("1/2 tasks passed"));
935        assert!(card.contains("login"));
936        assert!(card.contains("basket"));
937        assert!(card.contains("PASS"));
938        assert!(card.contains("FAIL"));
939        assert!(card.contains("! run: timeout"));
940        // The tool trace is the max-turns diagnostic — it must render.
941        assert!(card.contains("trace: navigate_page -> take_snapshot"));
942        // Cost column: priced row shows the value, unpriced shows "-", header +
943        // total present.
944        assert!(card.contains("cost$"), "scorecard has a cost column");
945        assert!(card.contains("0.01000"), "priced row shows its cost");
946        assert!(
947            card.contains("est. $0.01000 total"),
948            "header shows total cost"
949        );
950    }
951
952    #[test]
953    fn estimate_cost_is_tokens_times_price() {
954        // 1M in @ $0.07 + 1M out @ $0.10 = $0.17.
955        let c = estimate_cost_usd(1_000_000, 1_000_000, 0.07, 0.10);
956        assert!((c - 0.17).abs() < 1e-9, "got {c}");
957        // The real qwen3-235b matrix run: 73477 in / 429 out.
958        let q = estimate_cost_usd(73477, 429, 0.071, 0.10);
959        assert!((0.005..0.006).contains(&q), "qwen run ~ $0.0053, got {q}");
960        assert_eq!(estimate_cost_usd(0, 0, 1.0, 1.0), 0.0);
961    }
962
963    #[test]
964    fn price_table_known_and_unknown() {
965        assert_eq!(
966            model_price_per_mtok("qwen/qwen3-235b-a22b-2507"),
967            Some((0.0710, 0.1000))
968        );
969        assert_eq!(
970            model_price_per_mtok("anthropic/claude-opus-4.8"),
971            Some((5.0, 25.0))
972        );
973        assert_eq!(model_price_per_mtok("nonexistent/model"), None);
974    }
975
976    /// A provider whose `model_name()` is in the price table and that reports real
977    /// token usage — lets us assert `run_task_once` derives cost offline, with no
978    /// network or live browser.
979    struct PricedMock;
980    impl LlmProvider for PricedMock {
981        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
982            Ok(CompletionResponse {
983                content: vec![ContentBlock::Text {
984                    text: "the answer is 42".to_string(),
985                }],
986                stop_reason: StopReason::EndTurn,
987                reasoning: None,
988                usage: TokenUsage {
989                    input_tokens: 1000,
990                    output_tokens: 100,
991                    ..Default::default()
992                },
993                model: None,
994            })
995        }
996        fn model_name(&self) -> Option<&str> {
997            Some("qwen/qwen3-235b-a22b-2507")
998        }
999    }
1000
1001    /// Regression: the per-task cost must be derived from the static price table
1002    /// using the REAL token counts (folded from the run trace). The bug was that
1003    /// `run_task_once` only set `cost_usd = out.estimated_cost_usd` — `None` for
1004    /// OpenRouter — so every agent row in the scorecard printed `-`.
1005    #[tokio::test]
1006    async fn run_task_once_derives_cost_from_price_table() {
1007        let provider = Arc::new(PricedMock);
1008        let tools: Vec<Arc<dyn Tool>> = Vec::new();
1009        let ctx = ExecutionContext::default();
1010        let task = BenchTask {
1011            name: "cost".to_string(),
1012            difficulty: "easy".to_string(),
1013            allow_hosts: vec![],
1014            instruction: "say the answer".to_string(),
1015            oracle: Oracle::AgentAnswerContains("42".to_string()),
1016            max_turns: 1,
1017            tools: vec![],
1018        };
1019        let r = run_task_once(&provider, &tools, None, &ctx, &task).await;
1020        // The run must produce token counts (folded from the trace) ...
1021        assert!(
1022            r.input_tokens > 0,
1023            "token counts must be captured from the run (got {})",
1024            r.input_tokens
1025        );
1026        // ... and cost must derive from the price table using THOSE counts — not
1027        // stay None (OpenRouter reports no per-call cost).
1028        let (pin, pout) = model_price_per_mtok("qwen/qwen3-235b-a22b-2507").unwrap();
1029        let expected = estimate_cost_usd(r.input_tokens, r.output_tokens, pin, pout);
1030        assert_eq!(
1031            r.cost_usd,
1032            Some(expected),
1033            "cost must derive from the price table using the real token counts"
1034        );
1035        assert_ne!(r.cost_usd, Some(0.0), "cost must be nonzero for a real run");
1036    }
1037}