chrome-agent 0.15.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
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
//! 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 on the element could be aimed at, and the \
             reading did not change while it was watched"
        }
        "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::*;

    use crate::verdict::Delivery;

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

    /// The two documents that reprint this module's tables in markdown.
    ///
    /// There were five copies of the verdict/reason/next mapping in print and exactly one of
    /// them — the guide's — was confronted with `next_for`. A table an agent copies its
    /// branching from is not documentation, it is a second implementation, and the four
    /// unchecked ones were free to promise a `next` this module does not return. The `delivery`
    /// table had two copies and neither was checked.
    const SKILL: &str = include_str!("../skills/chrome-agent/SKILL.md");
    const README: &str = include_str!("../README.md");

    /// `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
            );
        }
    }

    /// A markdown copy of the table, as `(verdict, reasons, nexts)`.
    ///
    /// One reader for two shapes: `SKILL.md` gives each reason its own row, `README.md` merges
    /// several into one (`` `tree_delta`, `nodes_moved`, `focus_only` ``). Neither can use the
    /// guide's fixed-column reader, and giving each its own would be a third parser to keep in
    /// step — so a cell is read as a LIST, which the one-reason case is a case of.
    fn markdown_rows(doc: &str) -> Vec<(String, Vec<String>, Vec<String>)> {
        const HEADER: &str = "| `verdict` | `verdict_reason` | `next` |";
        let table = doc.split_once(HEADER).expect("the verdict table's header row").1;
        table
            .lines()
            .map(str::trim)
            .skip_while(|line| !line.starts_with('|'))
            .skip(1) // the |---|---| separator
            .take_while(|line| line.starts_with('|'))
            .map(|line| {
                let cells: Vec<&str> = line.trim_matches('|').split('|').collect();
                assert!(cells.len() >= 3, "a verdict row needs three columns: {line}");
                (bare(cells[0]), cell_list(cells[1], ','), cell_list(cells[2], '/'))
            })
            .collect()
    }

    /// A markdown cell's token, without the backticks and bolding the prose dresses it in.
    fn bare(cell: &str) -> String {
        cell.replace(['`', '*'], "").trim().to_string()
    }

    fn cell_list(cell: &str, separator: char) -> Vec<String> {
        cell.split(separator).map(bare).filter(|token| !token.is_empty()).collect()
    }

    /// The same re-borrow `leak` does, from a markdown document: the token sits inside
    /// backticks there, so whitespace is not what separates it.
    fn leak_from(doc: &'static str, reason: &str) -> &'static str {
        doc.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
            .find(|word| *word == reason)
            .expect("the reason came from this document")
    }

    /// The two markdown reprints of the ladder promise the same `next` this module returns.
    ///
    /// Five copies of this mapping were in print and one — the guide's — was confronted with
    /// `next_for`. A table an agent branches on is a second implementation of `next_for`, and
    /// an unchecked one is free to drift: `proceed` and `confirm` are opposite branches of the
    /// closed set of six, so a single wrong cell makes an agent do a different thing.
    #[test]
    fn every_markdown_copy_of_the_table_is_the_mapping_this_module_implements() {
        for (name, doc, expected) in
            [("skills/chrome-agent/SKILL.md", SKILL, 18), ("README.md", README, 12)]
        {
            let rows = markdown_rows(doc);
            assert_eq!(rows.len(), expected, "{name} lost rows, or grew columns: {rows:?}");
            for (verdict, reasons, nexts) in rows {
                assert!(!reasons.is_empty(), "{name}: {verdict} names no reason");
                for reason in &reasons {
                    let seen = Assessment {
                        verdict: parse_verdict(&verdict),
                        reason: leak_from(doc, reason),
                        page: PageSight::Readable,
                    };
                    assert_eq!(
                        next_for(seen).as_str(),
                        nexts[0],
                        "{name} promises {} for {verdict}/{reason}",
                        nexts[0]
                    );
                    // A cell naming two tokens is the documented exception, and it is exactly
                    // one row. Asserting WHICH row is the point: without it, any row could grow
                    // a second answer and this test would check it against the blind page.
                    match nexts.len() {
                        1 => {}
                        2 => {
                            assert_eq!(
                                reason, "value_kept",
                                "{name}: only a confirmed write on a page that could not be \
                                 read has two answers, not {verdict}/{reason}"
                            );
                            let blind = Assessment { page: PageSight::Unreadable, ..seen };
                            assert_eq!(
                                next_for(blind).as_str(),
                                nexts[1],
                                "{name} promises {} for {verdict}/{reason} on a blind page",
                                nexts[1]
                            );
                        }
                        n => panic!("{name}: {verdict}/{reason} names {n} tokens in one cell"),
                    }
                }
            }
        }
    }

    /// The `delivery` readings the guide's fixed-column block lists.
    ///
    /// Scoped to the section rather than matched by shape: `intercepted` is a word of the
    /// verdict table above too, and a reader that took every line starting with it would mix
    /// the two blocks. A row sits at the block's own indent; the wrapped continuations that
    /// follow one sit deeper, and the `#` notes below the block end it.
    fn delivery_rows_in_the_guide() -> Vec<&'static str> {
        let section = GUIDE
            .split_once("\"delivery\" on a pointer-targeted action")
            .expect("the delivery block's own heading")
            .1;
        let mut rows = Vec::new();
        let mut indent = None;
        for line in section.lines() {
            let body = line.trim_start();
            if body.is_empty() {
                continue;
            }
            if body.starts_with('#') {
                break;
            }
            let depth = line.len() - body.len();
            match indent {
                None if body.starts_with("target_hit") => indent = Some(depth),
                None => continue,
                Some(row) if depth != row => continue, // a wrapped continuation
                Some(_) => {}
            }
            rows.push(body.split_whitespace().next().expect("a non-empty line"));
        }
        rows
    }

    /// The same readings, from `SKILL.md`'s markdown table.
    fn delivery_rows_in_the_skill() -> Vec<String> {
        const HEADER: &str = "| `delivery` | Means | Licence |";
        SKILL
            .split_once(HEADER)
            .expect("the delivery table's header row")
            .1
            .lines()
            .map(str::trim)
            .skip_while(|line| !line.starts_with('|'))
            .skip(1)
            .take_while(|line| line.starts_with('|'))
            .map(|line| bare(line.trim_matches('|').split('|').next().expect("a first cell")))
            .collect()
    }

    /// Both copies of the `delivery` table name the six readings the code can produce, in order.
    ///
    /// `Delivery::parse` answers `not_probed` for anything it does not recognise, and that is
    /// the right answer where it is used — a response written by an older binary carries a
    /// token this one has not been taught, and "no evidence" is what that means. It is the
    /// wrong answer for a documentation table: a reading invented or misspelled here would
    /// collapse onto the floor and read as verified. So a row must ROUND-TRIP, not merely
    /// parse, and the count is asserted as well — a row this reader skips would otherwise
    /// leave the remaining five agreeing with themselves.
    #[test]
    fn both_copies_of_the_delivery_table_name_the_readings_the_code_produces() {
        let expected = [
            Delivery::TargetHit,
            Delivery::Intercepted,
            Delivery::OffTarget,
            Delivery::NotSettled,
            Delivery::JsDispatch,
            Delivery::NotProbed,
        ];
        let guide: Vec<String> =
            delivery_rows_in_the_guide().into_iter().map(str::to_string).collect();
        for (name, rows) in
            [("llm-guide.txt", guide), ("skills/chrome-agent/SKILL.md", delivery_rows_in_the_skill())]
        {
            assert_eq!(
                rows.len(),
                expected.len(),
                "{name} lists {} delivery readings, the code has {}: {rows:?}",
                rows.len(),
                expected.len()
            );
            for (row, want) in rows.iter().zip(expected) {
                assert_eq!(
                    Delivery::parse(row).as_str(),
                    row.as_str(),
                    "{name} names a delivery reading the code does not have: {row}"
                );
                assert_eq!(
                    Delivery::parse(row),
                    want,
                    "{name} lists the readings in another order: expected {} here, found {row}",
                    want.as_str()
                );
            }
        }
    }

    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(
            "Two readings of the aim point disagreed, so it was still moving when it was measured — nothing was dispatched, rather than dispatched at a coordinate the target had already left. This is the one rung where the repeat is the fix and is safe, because the page saw no event: run `wait` for the movement to end, then run the action again. A point that had STOPPED moving and was still unaimable reports `aim_point_off_target` instead, and that one does not improve on a repeat.",
        ),
        "aim_point_off_target" => Some(
            "No point on the element could be aimed at and the reading was stable — two probes 30ms apart agreed — so nothing was dispatched and a repeat measures the same coordinate. Two shapes reach here, and `aim` tells them apart: a coordinate on screen means the element has no box a pointer can reach (an inline link laid out across a gap, a container clipped to nothing), and a coordinate outside the viewport means the page is holding it there, which the probe's own scroll already failed to change. Run `inspect` to see where the element sits: for the first, aim at a child that has a box of its own; for the second, change the page's state — dismiss the layer pinning it — because no scroll will move it.",
        ),
        // `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, and a repeat measures the same point. Run `inspect`: aim at a child with a box of its own, or clear whatever pins the element off screen.",
        ),
        "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);
    }
}