chrome-agent 0.12.0

Browser automation for AI agents. Single binary, zero deps, CDP direct to Chrome.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
//! What a verdict hands on: the sentence a person reads, the token an agent branches on, and
//! the advice each rung owes its caller in both lengths.
//!
//! Split out of `verdict.rs` for the repo's 1000-line file cap and re-exported from it, so
//! every call site stays `crate::verdict::gloss` / `crate::verdict::next_for` /
//! `crate::verdict::hint_for` and the tables still sit one `pub use` away from the ladder they
//! describe. `hint_for` moved here when the ladder grew its `value_kept` rung: it is one table
//! per reason like the other three, and it belongs beside the short form it is cut down to.

use std::fmt;

use crate::verdict::{Assessment, PageSight, Verdict};

/// One sentence naming what the verdict word means, for a reader who has never seen the
/// taxonomy.
///
/// `verdict: unchanged (identical_tree)` reads as "nothing happened", which is the one
/// conclusion this whole module exists to forbid, and `unknown` reads as a broken tool rather
/// than as an unverified action. Both are jargon at the point where a person judges the output.
///
/// One table, here, next to `hint_for`: the text renderer reads it rather than writing prose of
/// its own, so the wording cannot drift away from the classifier that produced the token. Keyed
/// on the reason, because the reason is what the sentence is about; the per-verdict arm at the
/// bottom is the floor for a token this table has not been taught yet.
#[must_use]
pub fn gloss(assessment: Assessment) -> &'static str {
    match assessment.reason {
        "tree_delta" => "the page moved in a way that can be pointed at",
        "nodes_moved" => "the same nodes, in a different order",
        "focus_only" => "nothing moved but focus, which is the only sign the action arrived",
        // Names its evidence, because it is the one `changed` with no delta to point at: the
        // tree could not show this write (a secret field renders as a fixed marker, and a first
        // action has no tree to compare), so the read-back on the element itself is what the
        // claim rests on. "the element", not "the field": a select and a check reach this rung
        // through the same measurement, and a checkbox is neither a field nor written to.
        "value_kept" => "the element held what was asked of it when it was read back, and the \
                         tree could not show it",
        "values_lost" => "the page moved, and a field that held a value now holds none",
        "document_replaced" => "the document was replaced, so every stored uid is dead",
        "hit_test_receiver" => "another element occupied the point aimed at and received the event",
        "modal_dialog" => "a modal dialog holds the top layer and receives every event outside it",
        "value_reverted" => "the element held nothing when it was read back",
        "value_rewritten" => "the element holds something other than what was written",
        "delivered_no_change" => {
            "the event reached the target and the tree stayed still while it was watched"
        }
        // "unchanged" is the word an agent most easily over-reads; the gloss carries the
        // limit that the word cannot.
        "identical_tree" => "the tree was identical while the tool watched — which is not the \
                             same as the action having no effect",
        "no_baseline" => "unverified — there was no earlier snapshot to compare against",
        "read_failed" => "unverified — the action ran and the page could not be read afterwards",
        "identity_unreadable" => {
            "unverified — the two trees may not belong to the same document"
        }
        "scroll_not_settled" => "nothing was dispatched: the aim point was still moving",
        "aim_point_off_target" => {
            "nothing was dispatched: no point inside the element could be aimed at"
        }
        "reporting_disabled" => "not checked — the page was never re-read (--verdict off)",
        _ => match assessment.verdict {
            Verdict::Changed => "the page moved",
            Verdict::Navigated => "the document was replaced",
            Verdict::Intercepted => "another element received the event",
            Verdict::NotKept => "the element does not hold what was written",
            Verdict::NoEffect => "delivered, and nothing moved while it was watched",
            Verdict::Unchanged => "the tree was identical while the tool watched",
            Verdict::Unknown => "unverified",
            Verdict::NotChecked => "not checked",
        },
    }
}

/// What the caller should do next, in one token from a closed vocabulary.
///
/// The verdict says what happened; this says what to do about it. Six tokens, and the closed
/// set is the point: an agent can branch on it without parsing prose, and a new rung on the
/// ladder cannot introduce a seventh behaviour by accident.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Next {
    /// The action did what it was for. Carry on.
    Proceed,
    /// Read the page before acting again: the uids are dead, or nothing is known.
    Inspect,
    /// Repeat this action. Only ever from a rung that proves nothing was dispatched.
    Retry,
    /// Establish the outcome some other way before treating this as done.
    Confirm,
    /// Something is in the way. Deal with it, then act again.
    Dismiss,
    /// Do not repeat this action; it will produce the same answer.
    Stop,
}

impl Next {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Proceed => "proceed",
            Self::Inspect => "inspect",
            Self::Retry => "retry",
            Self::Confirm => "confirm",
            Self::Dismiss => "dismiss",
            Self::Stop => "stop",
        }
    }
}

impl fmt::Display for Next {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Map a verdict onto the one thing to do about it. Pure, total, and the only place the
/// mapping exists.
///
/// # Why `unknown` never yields `retry`
///
/// This is the rule the field exists to make structural. An `unknown` verdict means the tool
/// could not observe what the action did — NOT that the action did not happen. A blind repeat
/// there is a second real click, a second real fill, a second order, and the page has no way to
/// tell it from a deliberate one. So every "I don't know" answers `inspect`: read the state the
/// action was supposed to produce, then decide. The rule used to live only as a sentence in
/// `hints.rs`'s contract, where nothing could check it.
///
/// `scroll_not_settled` is the single exception, and it is one because it is not an admission of
/// ignorance about a dispatch — it is the statement that NOTHING WAS DISPATCHED. There is no
/// first action for the retry to duplicate.
///
/// # Why `proceed` is withdrawn when the page could not be read
///
/// This is the ONE place `next` deliberately answers something other than what the verdict alone
/// implies, and it is the verdict/next split doing its job: the verdict describes an observation,
/// `next` prescribes an action, and the two do not always have the same subject.
///
/// A Group A rung is measured on the handle this action touched, so it stays true when the
/// post-action page read fails — `value_kept` says the field holds what was written, and it does.
/// But `proceed` means "carry on", and carrying on happens against the whole page, which in that
/// case was never seen. Two things are true at once: the field took the write, and everything
/// else that may have moved is unobserved. The verdict owns the first, `next` owns the second.
///
/// So a `proceed` becomes `inspect` whenever the page is `Unreadable`. Written as a rule over the
/// token rather than as an arm for `value_kept`, because the dangerous default is the other way
/// round: a rung added later that answers `proceed` would otherwise inherit "carry on while
/// blind" silently, and this way it cannot. `retry` is untouched — it is only ever reached from a
/// rung that proves nothing was dispatched, where there is nothing to be blind about — and
/// `--verdict off` is untouched too, because a caller who declined the read owns that silence
/// (`PageSight::Readable` covers the declined case for exactly that reason).
#[must_use]
pub fn next_for(assessment: Assessment) -> Next {
    let next = next_when_the_page_was_read(assessment);
    // The one divergence, argued above: "carry on" is about the whole page, and this response
    // saw none of it.
    if next == Next::Proceed && assessment.page == PageSight::Unreadable {
        return Next::Inspect;
    }
    next
}

/// The mapping itself, for a response that got to see the page.
// Two pairs of arms below share a token and not a reason (`navigated`/`unknown` → inspect,
// `changed`/`not_checked` → proceed). Merging them would delete the rationale each carries,
// and the two halves of each pair move independently: `not_checked` becomes `proceed` only
// because the caller chose the silence.
#[allow(clippy::match_same_arms)]
fn next_when_the_page_was_read(assessment: Assessment) -> Next {
    match assessment.reason {
        "scroll_not_settled" => Next::Retry,
        // A `changed` whose hint forbids treating the submit as done cannot answer `proceed`:
        // a form that cleared itself on a successful submit and one that threw the input away
        // look identical here, and only the caller can tell them apart.
        "values_lost" => Next::Confirm,
        _ => match assessment.verdict {
            Verdict::Changed => Next::Proceed,
            // The uids are dead and the page is a different one. Nothing is wrong; nothing
            // stored is usable either.
            Verdict::Navigated => Next::Inspect,
            // Neither is a failure and neither is proof of success: the tool watched and saw
            // nothing, and everything it cannot see (canvas, CSS, a late handler) looks like
            // this. Confirm elsewhere rather than repeat.
            Verdict::Unchanged | Verdict::NoEffect => Next::Confirm,
            Verdict::Intercepted => Next::Dismiss,
            // The same write produces the same answer, so a repeat only edits the page again.
            Verdict::NotKept => Next::Stop,
            Verdict::Unknown => Next::Inspect,
            // The caller turned reporting off; the silence is theirs, not the page's.
            Verdict::NotChecked => Next::Proceed,
        },
    }
}


#[cfg(test)]
mod tests {
    use super::*;
    use crate::verdict::{Delivered, Delivery, Observation, Postcondition, classify};

    /// Every reason the ladder can actually produce, derived by running it rather than by
    /// keeping a list beside it. A new rung appears here for free, and the assertion below
    /// makes it appear as a failing test until it has a gloss and a next step.
    pub(super) fn every_assessment() -> Vec<Assessment> {
        let mut out = Vec::new();
        let observations = [
            Observation::ReportingDisabled,
            Observation::ReadFailed,
            Observation::NoBaseline,
            Observation::Compared {
                document_changed: false,
                identity_known: false,
                edits: 0,
                moved: 0,
                focus_moved: false,
                values_lost: 0,
            },
            Observation::Compared {
                document_changed: true,
                identity_known: true,
                edits: 0,
                moved: 0,
                focus_moved: false,
                values_lost: 0,
            },
        ];
        let counted = [(0, 0, 0), (2, 0, 0), (0, 2, 0), (0, 0, 1)];
        let mut all: Vec<Observation> = observations.to_vec();
        for (edits, moved, values_lost) in counted {
            for focus_moved in [false, true] {
                all.push(Observation::Compared {
                    document_changed: false,
                    identity_known: true,
                    edits,
                    moved,
                    focus_moved,
                    values_lost,
                });
            }
        }
        for observation in all {
            for how in [
                Delivery::TargetHit,
                Delivery::Intercepted,
                Delivery::OffTarget,
                Delivery::NotSettled,
                Delivery::JsDispatch,
                Delivery::NotProbed,
            ] {
                for modal_receiver in [false, true] {
                    for observed_after_ms in [None, Some(60)] {
                        let delivered = Delivered { how, modal_receiver, observed_after_ms };
                        for postcondition in [
                            Postcondition::NotRead,
                            Postcondition::Kept,
                            Postcondition::Discarded,
                            Postcondition::Rewritten,
                        ] {
                            out.push(classify(observation, delivered, postcondition));
                        }
                    }
                }
            }
        }
        out
    }

    fn reasons() -> std::collections::BTreeSet<&'static str> {
        every_assessment().into_iter().map(|a| a.reason).collect()
    }

    /// The ladder as it stands. Pinned so a rung added without a gloss fails here, and so the
    /// coverage assertions below are checking a list somebody has actually looked at.
    #[test]
    fn the_ladder_produces_exactly_these_reasons() {
        let expected: std::collections::BTreeSet<&str> = [
            "aim_point_off_target",
            "delivered_no_change",
            "document_replaced",
            "focus_only",
            "hit_test_receiver",
            "identical_tree",
            "identity_unreadable",
            "modal_dialog",
            "no_baseline",
            "nodes_moved",
            "read_failed",
            "reporting_disabled",
            "scroll_not_settled",
            "tree_delta",
            "value_kept",
            "value_reverted",
            "value_rewritten",
            "values_lost",
        ]
        .into_iter()
        .collect();
        assert_eq!(reasons(), expected);
    }

    /// A gloss per reason, and none of them the per-verdict floor: the floor is for a token
    /// this table has not been taught, and every reason the ladder emits is one it has.
    #[test]
    fn every_reason_has_its_own_gloss() {
        let mut seen = std::collections::HashSet::new();
        for assessment in every_assessment() {
            let text = gloss(assessment);
            let floor = gloss(Assessment { verdict: assessment.verdict, reason: "unwritten", page: PageSight::Readable });
            assert_ne!(
                text, floor,
                "{} / {} falls back to the per-verdict floor",
                assessment.verdict, assessment.reason
            );
            assert!(!text.is_empty());
            seen.insert(assessment.reason);
        }
        assert_eq!(seen.len(), reasons().len());
    }

    /// Two reasons that shared a sentence would be two rungs the reader cannot tell apart —
    /// which is the whole failure the reason token exists to fix, one level up.
    #[test]
    fn no_two_reasons_share_a_gloss() {
        let mut by_gloss: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
        for assessment in every_assessment() {
            if let Some(other) = by_gloss.insert(gloss(assessment), assessment.reason) {
                assert_eq!(
                    other, assessment.reason,
                    "{other} and {} share a gloss",
                    assessment.reason
                );
            }
        }
    }

    /// The gloss for `unchanged` must not read as "nothing happened" — the one conclusion the
    /// taxonomy forbids, and the reason this table exists at all.
    #[test]
    fn the_unchanged_gloss_refuses_to_conclude_no_effect() {
        let text = gloss(Assessment { verdict: Verdict::Unchanged, reason: "identical_tree", page: PageSight::Readable });
        assert!(text.contains("not the same as"), "{text}");
        assert!(!text.contains("no effect having"), "{text}");
    }

    /// The two jargon words get their human equivalent, since the token itself misleads:
    /// `unknown` reads as a broken tool, `not_checked` as a missing feature.
    #[test]
    fn the_uncertain_verdicts_are_glossed_in_plain_words() {
        for (reason, word) in
            [("no_baseline", "unverified"), ("read_failed", "unverified"), ("identity_unreadable", "unverified")]
        {
            let text = gloss(Assessment { verdict: Verdict::Unknown, reason, page: PageSight::Readable });
            assert!(text.starts_with(word), "{reason}: {text}");
        }
        let text = gloss(Assessment { verdict: Verdict::NotChecked, reason: "reporting_disabled", page: PageSight::Readable });
        assert!(text.starts_with("not checked"), "{text}");
    }

    /// The mapping the whole field exists for, verdict by verdict.
    #[test]
    fn each_verdict_maps_to_its_own_next_step() {
        for (verdict, reason, expected) in [
            (Verdict::Changed, "tree_delta", Next::Proceed),
            (Verdict::Navigated, "document_replaced", Next::Inspect),
            (Verdict::Unchanged, "identical_tree", Next::Confirm),
            (Verdict::NoEffect, "delivered_no_change", Next::Confirm),
            (Verdict::Intercepted, "hit_test_receiver", Next::Dismiss),
            (Verdict::Intercepted, "modal_dialog", Next::Dismiss),
            (Verdict::NotKept, "value_reverted", Next::Stop),
            (Verdict::NotKept, "value_rewritten", Next::Stop),
            (Verdict::Unknown, "no_baseline", Next::Inspect),
            (Verdict::Unknown, "read_failed", Next::Inspect),
            (Verdict::Unknown, "identity_unreadable", Next::Inspect),
            (Verdict::Unknown, "aim_point_off_target", Next::Inspect),
            (Verdict::Unknown, "scroll_not_settled", Next::Retry),
            (Verdict::NotChecked, "reporting_disabled", Next::Proceed),
            (Verdict::Changed, "values_lost", Next::Confirm),
            (Verdict::Changed, "value_kept", Next::Proceed),
        ] {
            let assessment = Assessment { verdict, reason, page: PageSight::Readable };
            assert_eq!(next_for(assessment), expected, "{verdict} / {reason}");
        }
    }

    /// The rule the field was added to make structural: an unobserved action must never be
    /// repeated blind, because the repeat is a second real click. The one exception is the rung
    /// that says nothing was dispatched, where there is no first action to duplicate.
    #[test]
    fn an_unobserved_action_is_never_answered_with_a_retry() {
        for assessment in every_assessment() {
            if assessment.verdict != Verdict::Unknown {
                continue;
            }
            if assessment.reason == "scroll_not_settled" {
                assert_eq!(next_for(assessment), Next::Retry, "nothing was dispatched here");
                continue;
            }
            assert_eq!(
                next_for(assessment),
                Next::Inspect,
                "{} would send an agent to repeat an action that may have landed",
                assessment.reason
            );
        }
    }

    /// `retry` is the dangerous token, so it is reachable from exactly one rung.
    #[test]
    fn only_a_dispatch_that_never_happened_earns_a_retry() {
        let retriable: Vec<&str> = every_assessment()
            .into_iter()
            .filter(|a| next_for(*a) == Next::Retry)
            .map(|a| a.reason)
            .collect();
        assert!(retriable.iter().all(|r| *r == "scroll_not_settled"), "{retriable:?}");
        assert!(!retriable.is_empty(), "the token must stay reachable, or it is not a vocabulary");
    }

    /// A closed vocabulary of six, and no token spelled two ways.
    #[test]
    fn the_vocabulary_is_six_tokens_and_round_trips() {
        let all = [Next::Proceed, Next::Inspect, Next::Retry, Next::Confirm, Next::Dismiss, Next::Stop];
        let spelled: std::collections::BTreeSet<&str> = all.iter().map(|n| n.as_str()).collect();
        assert_eq!(spelled.len(), 6);
        assert_eq!(
            spelled,
            ["confirm", "dismiss", "inspect", "proceed", "retry", "stop"].into_iter().collect()
        );
        for next in all {
            assert_eq!(next.to_string(), next.as_str());
        }
    }
}

/// The guide's own table, checked against the code that produces it.
///
/// `llm-guide.txt` is compiled into `--help` and is what an agent copies its expectations from,
/// and `next` is the field it is told to branch on. A table that drifts from `next_for` would
/// send every reader of the guide down the branch the tool does not take — including the one
/// branch that must never be taken, `retry` on an action that may have landed.
#[cfg(test)]
mod guide {
    use super::*;

    const GUIDE: &str = include_str!("../llm-guide.txt");

    /// `verdict reason next …`, as the guide's fixed-column block spells it.
    fn rows() -> Vec<(&'static str, &'static str, &'static str)> {
        let known = [
            "changed",
            "navigated",
            "intercepted",
            "not_kept",
            "no_effect",
            "unchanged",
            "unknown",
            "not_checked",
        ];
        GUIDE
            .lines()
            .map(str::trim)
            .filter_map(|line| {
                let mut cols = line.split_whitespace();
                let verdict = cols.next()?;
                if !known.contains(&verdict) {
                    return None;
                }
                let (reason, next) = (cols.next()?, cols.next()?);
                // The `delivery` table below shares the word "intercepted" in its first
                // column. A row of this table always names a token of the vocabulary third.
                let vocabulary =
                    [Next::Proceed, Next::Inspect, Next::Retry, Next::Confirm, Next::Dismiss, Next::Stop];
                vocabulary.iter().any(|n| n.as_str() == next).then_some((verdict, reason, next))
            })
            .collect()
    }

    #[test]
    fn the_guide_table_is_the_mapping_this_module_implements() {
        let rows = rows();
        assert_eq!(rows.len(), 18, "the guide lost rows, or grew columns: {rows:?}");
        for (verdict, reason, next) in rows {
            let assessment = Assessment { verdict: parse_verdict(verdict), reason: leak(reason), page: PageSight::Readable };
            assert_eq!(
                next_for(assessment).as_str(),
                next,
                "the guide promises {next} for {verdict}/{reason}"
            );
        }
    }

    /// The table maps one reason to one `next`, and there is exactly one pair where that shape
    /// is not enough — a confirmed write on a page that could not be read. The row states the
    /// readable case, so the exception has to be written under it in words, or an agent copying
    /// the table would believe `value_kept` always means "carry on".
    #[test]
    fn the_guide_states_the_one_next_that_depends_on_more_than_the_reason() {
        let blind = Assessment {
            verdict: Verdict::Changed,
            reason: "value_kept",
            page: PageSight::Unreadable,
        };
        assert_eq!(next_for(blind), Next::Inspect, "the exception this note is about");
        assert!(
            GUIDE.contains("next=inspect"),
            "the guide must name the token the exception answers"
        );
        assert!(
            GUIDE.contains("the field is confirmed, the"),
            "and say which of the two facts each half of the response carries"
        );
    }

    /// Every reason the ladder emits appears in the guide. A rung an agent is never told about
    /// is a branch it cannot take.
    #[test]
    fn the_guide_documents_every_reason_the_ladder_can_produce() {
        let documented: std::collections::BTreeSet<&str> =
            rows().into_iter().map(|(_, reason, _)| reason).collect();
        for assessment in super::tests::every_assessment() {
            assert!(
                documented.contains(assessment.reason),
                "{} is undocumented in llm-guide.txt",
                assessment.reason
            );
        }
    }

    fn parse_verdict(word: &str) -> Verdict {
        match word {
            "changed" => Verdict::Changed,
            "navigated" => Verdict::Navigated,
            "intercepted" => Verdict::Intercepted,
            "not_kept" => Verdict::NotKept,
            "no_effect" => Verdict::NoEffect,
            "unchanged" => Verdict::Unchanged,
            "unknown" => Verdict::Unknown,
            "not_checked" => Verdict::NotChecked,
            other => panic!("unknown verdict word in the guide: {other}"),
        }
    }

    /// `Assessment.reason` is `&'static str`, and the guide is a `&'static str` too — this only
    /// re-borrows a slice of it.
    fn leak(reason: &str) -> &'static str {
        GUIDE
            .split_whitespace()
            .find(|word| *word == reason)
            .expect("the reason came from the guide")
    }
}

/// What the agent should do next, when the verdict alone does not say.
///
/// Keyed on the reason, except for the one pair where the reason is not the whole story: a
/// confirmed write whose page could not be read. `read_failed` used to hold that slot and carry
/// the blindness in words; now that the Group A rung outranks it, this is where the blindness is
/// said — otherwise the only trace of it on the response would be the ABSENCE of `changed`, which
/// is the ambiguity the whole module exists to remove.
#[must_use]
pub fn hint_for(assessment: Assessment) -> Option<&'static str> {
    if assessment.reason == "value_kept" {
        return match assessment.page {
            // Both facts, in that order: what was measured, then what was not.
            PageSight::Unreadable => Some(
                "The element holds what was asked of it — that was read back on the element itself, which is why the verdict is `changed` and not `unknown`. Reading the PAGE afterwards failed, so nothing is known about what else moved: a navigation, a validation message, a field the form cleared. Run `inspect` to see the current state before acting on anything stored, and do not send the action a second time — it landed.",
            ),
            // A confirmed write on a page that was read needs no advice.
            PageSight::Readable => None,
        };
    }
    match assessment.reason {
        "no_baseline" => Some(
            "No snapshot existed before this action, so nothing could be compared. Run `inspect` to establish one; the next action on this page will report what changed.",
        ),
        "read_failed" => Some(
            "The action ran, but reading the page afterwards failed, so what it did is unknown. Run `inspect` to see the current state.",
        ),
        "identical_tree" => Some(
            "Nothing in the accessibility tree changed while this was watched. That is not the same as the action having no effect: a click absorbed by an overlay, an effect the tree cannot see (canvas, styling), and a handler that runs after the window all look like this. Confirm with `inspect` or `eval` before repeating the action — a repeat is a second real action.",
        ),
        // The action itself writes a more specific hint naming the receiver; this is the
        // fallback for a response that lost it.
        "hit_test_receiver" => Some(
            "The point this was aimed at belongs to another element, which received the event instead — `intercepted_by` names it. Deal with that element first (a banner or scrim usually has to be dismissed), or aim somewhere the target is actually exposed. Nothing is known about what the target would have done.",
        ),
        "modal_dialog" => Some(
            "A modal dialog holds the top layer, so it receives every pointer event outside itself. Close it (press Escape, or click its own dismiss control) before acting on anything behind it.",
        ),
        "scroll_not_settled" => Some(
            "The aim point was still moving, or still outside the viewport, when the scroll was measured — so nothing was dispatched rather than dispatched at a coordinate that had already moved. Run `wait` (or `scroll` the target into view) and repeat the action.",
        ),
        "aim_point_off_target" => Some(
            "No point inside the element's own boxes could be aimed at, so nothing was dispatched. This is what an element clipped to nothing, or laid out across a gap, looks like. Try `--selector` on a child that has a box of its own.",
        ),
        // `changed` alone would read as plain success, and this is the shape where that costs
        // most: a form that "submits" and quietly discards what was typed into it.
        "values_lost" => Some(
            "The page moved, and a field that held a value before this action holds none after it — `values_lost` names each one and what it held. Two things look identical here: a form that submitted successfully and cleared itself, and a form that threw the input away without sending it. Confirm which before treating the submit as done: check for the page's own confirmation (`assert text --contains`), or the request itself with `network`. Re-filling and re-submitting risks a second submission of work that already went through.",
        ),
        // Both name the field to read and forbid the retry: re-filling is the reflex here,
        // and it is a second real edit that produces the same answer.
        "value_reverted" => Some(
            "The element held nothing when it was read back: `value.actual` is empty and `value.requested` is what was asked for. Do not fill it again — the same write already produced this, and a repeat is a second real edit against a field that discarded the first. A field that empties itself is a controlled component writing its own state over ours, or an input rejecting what it cannot parse (a number input given letters). Read `value.actual` to confirm, then either send a value of the type the field accepts, or drive the page's own control instead of the field. If the emptying may be a validator that runs later than the window in `observed_after_ms`, `wait` then `assert value` is what measures that.",
        ),
        "value_rewritten" => Some(
            "The element holds something other than what was asked for: `value.actual` is what it kept, `value.requested` is what was sent. This is what a mask, a trimmer or a normaliser does, and the write did land — in the page's own shape. Do not fill it again: the same write produces the same rewrite. Read both strings and decide whether `value.actual` is the value you wanted; if it is not, send it in the form the field accepts.",
        ),
        // The strong word carries its own limits: everything it cannot see, named.
        "delivered_no_change" => Some(
            "The event reached the target and the accessibility tree did not move within the window reported in `observed_after_ms`. Three effects are invisible to that measurement: a canvas or WebGL repaint, a change that is CSS-only (a class, an opacity, a transform), and a handler that runs after the window closed. If one of those is plausible, confirm with `screenshot` or `eval` rather than repeating the action.",
        ),
        _ => None,
    }
}

/// The same advice as `hint_for`, cut to what fits on a terminal line or two.
///
/// The full hints are written for an agent reading JSON, where length is cheap and a paragraph
/// naming every failure mode is worth having. In text mode the `not_kept` one rendered as seven
/// wrapped lines of about ninety words, which buried the three lines above it that carry the
/// news — `value: NOT KEPT …`, the verdict and the gloss.
///
/// A second curated table rather than a truncation of the first: cutting at a character count
/// ends mid-sentence, and cutting at the first sentence loses the imperative in most of these —
/// the prohibition in `value_reverted` is in the SECOND sentence, and it is the one line that
/// stops an agent from editing the page again. So each entry states the fact and the next
/// command, and where the full hint forbids a repeat, the short one forbids it too. The tests
/// below check that mechanically rather than trusting the wording.
///
/// `None` where `hint_for` is `None`: a verdict that needs no advice gets no line.
#[must_use]
pub fn short_hint(assessment: Assessment) -> Option<&'static str> {
    if assessment.reason == "value_kept" {
        return match assessment.page {
            PageSight::Unreadable => Some(
                "The element holds what was asked of it, read back on the element itself. The page after it could not be read: `inspect` before acting on anything stored, and do not send it a second time.",
            ),
            PageSight::Readable => None,
        };
    }
    match assessment.reason {
        // The prohibition comes first in both: re-filling is the reflex, and it is a second
        // real edit against a field that already refused the first one.
        "value_reverted" => Some(
            "Do not fill it again — the field discarded the first write. Read `value.actual`, then send a value the field accepts.",
        ),
        "value_rewritten" => Some(
            "Do not fill it again — the same write produces the same rewrite. Read `value.actual` and decide whether that is the value you wanted.",
        ),
        "values_lost" => Some(
            "Confirm the submit landed before re-filling — `assert text --contains` on the page's own confirmation, or `network`. Re-submitting may send the work twice.",
        ),
        "hit_test_receiver" => Some(
            "Deal with the element named above first, then repeat the action. Nothing is known about what the target would have done.",
        ),
        "modal_dialog" => Some(
            "Close the dialog (Escape, or its own dismiss control) before acting on anything behind it.",
        ),
        "scroll_not_settled" => Some(
            "Nothing was dispatched, so a repeat is safe: `wait` for the page to settle, then run this action again.",
        ),
        "aim_point_off_target" => Some(
            "Nothing was dispatched: the element has no aimable box. Try `--selector` on a child that has one.",
        ),
        "no_baseline" => Some(
            "Run `inspect` to establish a baseline; the next action on this page will report what changed.",
        ),
        "read_failed" => Some(
            "The action ran and the read after it did not. Run `inspect` to see the current state rather than repeating the action.",
        ),
        "identical_tree" => Some(
            "Not proof that the action had no effect. Confirm with `inspect` or `eval` rather than repeating — a repeat is a second real action.",
        ),
        "delivered_no_change" => Some(
            "Delivered, and the tree did not move inside the window. A canvas repaint, a CSS-only change or a late handler all look like this — confirm with `screenshot` or `eval` rather than repeating.",
        ),
        _ => None,
    }
}

#[cfg(test)]
mod short {
    use super::*;

    /// Roughly two lines on a normal terminal. The full text stays in `verdict_hint`.
    const LINE_PAIR: usize = 200;

    /// Advice a person needs and advice an agent needs are the same advice; only the budget
    /// differs. A reason with a full hint and no short one prints nothing in text mode.
    #[test]
    fn every_full_hint_has_a_short_form() {
        for assessment in super::tests::every_assessment() {
            assert_eq!(
                hint_for(assessment).is_some(),
                short_hint(assessment).is_some(),
                "{} / {} has one form and not the other",
                assessment.verdict,
                assessment.reason
            );
        }
    }

    /// The whole point of the second table: it has to be short, and it has to be whole
    /// sentences. A character-count truncation of the full hint would fail the second half.
    #[test]
    fn a_short_hint_is_short_and_ends_where_a_sentence_ends() {
        for assessment in super::tests::every_assessment() {
            let Some(hint) = short_hint(assessment) else { continue };
            assert!(
                hint.len() <= LINE_PAIR,
                "{} is {} chars, over the two-line budget: {hint}",
                assessment.reason,
                hint.len()
            );
            assert!(hint.ends_with('.'), "{} is cut mid-sentence: {hint}", assessment.reason);
            assert!(!hint.contains('…'), "{} was truncated, not written: {hint}", assessment.reason);
        }
    }

    /// The rule that survives the cut. Where the full hint forbids a repeat in words, the short
    /// one must too: shortening the advice must not shorten away the one line that stops an
    /// agent from clicking, filling or submitting a second time.
    #[test]
    fn shortening_never_drops_a_prohibition() {
        for assessment in super::tests::every_assessment() {
            let (Some(full), Some(short)) = (hint_for(assessment), short_hint(assessment)) else {
                continue;
            };
            if full.contains("Do not") {
                assert!(
                    short.contains("Do not"),
                    "{} forbids a repeat in full and not in short: {short}",
                    assessment.reason
                );
            }
            // The softer half of the same rule: a full hint that warns about doing the action
            // twice must leave the short form warning about it too, in whatever wording.
            let warns = |text: &str| {
                ["repeat", "again", "twice", "second"].iter().any(|w| text.contains(w))
            };
            if warns(full) {
                assert!(
                    warns(short),
                    "{} warns about a duplicate action in full and not in short: {short}",
                    assessment.reason
                );
            }
        }
    }

    /// `scroll_not_settled` is the one rung where the repeat IS the advice, so its short form
    /// must not read like the prohibitions above it.
    #[test]
    fn the_safe_retry_still_says_to_retry() {
        let assessment = Assessment { verdict: Verdict::Unknown, reason: "scroll_not_settled", page: PageSight::Readable };
        let short = short_hint(assessment).expect("a short hint");
        assert!(short.contains("again"), "{short}");
        assert!(!short.contains("Do not"), "{short}");
        assert_eq!(next_for(assessment), Next::Retry);
    }
}