newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
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
use super::*;

#[test]
fn response_style_preserves_the_configured_round_allowance() {
    let explain = PromptIntake::analyze("Explain how prompt receipts survive compaction.");
    let research = PromptIntake::analyze("Investigate the current compaction behavior.");

    assert_eq!(explain.disposition(), PromptDisposition::Explain);
    assert_eq!(research.disposition(), PromptDisposition::Research);
    assert_eq!(PromptDisposition::Ask.tool_round_limit(40), 0);
    assert_eq!(PromptDisposition::Explain.tool_round_limit(40), 40);
    assert_eq!(PromptDisposition::Research.tool_round_limit(40), 40);
    assert_eq!(PromptDisposition::Plan.tool_round_limit(40), 40);
}

// ── #1260: disposition inference as pure data ───────────────────────────

/// The pre-#1260 lists, reconstructed as an override — documents the cliff
/// durably: under the OLD data this prompt matched NOTHING ("what is" ≠
/// "what are"; research had "find out", not "largest") and was classified
/// Explain SOLELY by the trailing `?`, while the identical prompt minus its
/// `?` fell to Act. Any future change to this coupling is now deliberate.
fn pre_1260_lexicon() -> DispositionLexicon {
    DispositionLexicon {
        action: [
            "implement",
            "modify",
            "change",
            "create",
            "write",
            "edit",
            "delete",
            "fix",
            "build",
            "run ",
            "execute",
            "commit",
            "push",
            "open a pr",
            "open pr",
            "merge",
        ]
        .map(str::to_string)
        .to_vec(),
        research: [
            "research",
            "investigate",
            "look up",
            "find out",
            "analyze",
            "diagnose",
            "audit",
            "explore",
            "compare",
        ]
        .map(str::to_string)
        .to_vec(),
        explain: [
            "explain",
            "summarize",
            "describe",
            "what is",
            "why ",
            "how does",
            "how do",
        ]
        .map(str::to_string)
        .to_vec(),
        question_mark_disposition: PromptDisposition::Explain,
        // #1971's lists are not part of the reconstructed 2026-era data;
        // taking the defaults keeps this fixture about the #1260 cliff.
        ..DispositionLexicon::default()
    }
}

/// `infer_disposition_with` over a prompt's OWN extracted asks — the shape
/// production uses. #1971 gave the classifier a second input; these tests
/// still measure the same thing through it.
fn infer(prompt: &str, lexicon: &DispositionLexicon) -> PromptDisposition {
    let (asks, _) = super::extract_atomic_asks_with(prompt, lexicon);
    super::infer_disposition_with(prompt, &asks, lexicon)
}

#[test]
fn largest_files_question_classified_explain_via_question_mark_fallback_pre_1260() {
    let old = pre_1260_lexicon();
    assert_eq!(
        infer(LARGEST_FILES_PROMPT, &old),
        PromptDisposition::Explain,
        "under the OLD data the ? fallback alone decided"
    );
    assert_eq!(
        infer(LARGEST_FILES_PROMPT.trim_end_matches('?'), &old),
        PromptDisposition::Act,
        "…and the same prompt minus its ? fell off the cliff to Act"
    );
}

#[test]
fn new_defaults_classify_evidence_questions_by_content_not_the_cliff() {
    // "largest" (research data) decides — with or without the `?`.
    let with_q = PromptIntake::analyze(LARGEST_FILES_PROMPT);
    assert_eq!(with_q.disposition(), PromptDisposition::Research);
    let without_q = PromptIntake::analyze("What are the 10 largest Rust files in this workspace");
    assert_eq!(
        without_q.disposition(),
        PromptDisposition::Research,
        "content decides; removing the ? no longer flips the disposition"
    );
    // "what are" (explain data) catches the plural interrogative the old
    // list missed.
    let plural = PromptIntake::analyze("What are the tradeoffs of this design?");
    assert_eq!(plural.disposition(), PromptDisposition::Explain);
    // A bare statement matching nothing still defaults to Act.
    let act = PromptIntake::analyze("update the release notes for 0.8.0");
    assert_eq!(act.disposition(), PromptDisposition::Act);
}

#[test]
fn line_count_questions_classify_research_not_the_cliff() {
    // #1387: the regressed prompt. "line count" is evidence phrasing, so it
    // lands in Research — where `find` (sort=lines/show_lines) can answer it
    // read-only. It must NOT fall off the `?` cliff to Explain, and must NOT
    // require Act (a mutation grant) just to count lines.
    let regressed =
        PromptIntake::analyze("show me the 10 code files with the highest line counts?");
    assert_eq!(
        regressed.disposition(),
        PromptDisposition::Research,
        "line-count question is a Research/evidence turn, not Explain or Act"
    );
    for prompt in [
        "which files have the most lines",
        "the longest file in the repo",
        "files with the fewest lines",
    ] {
        assert_eq!(
            PromptIntake::analyze(prompt).disposition(),
            PromptDisposition::Research,
            "line-count evidence phrasing → Research: {prompt:?}"
        );
    }
}

#[test]
fn code_file_prompt_adds_source_scope_without_incident_specific_shape_guessing() {
    let intake = PromptIntake::analyze(
        "show me the 10 code files with the highest line counts in this repository?",
    );
    let card = intake.model_card();

    assert!(
        !card.contains("response_shape:"),
        "line-count/ranking keywords must not own presentation policy: {card}"
    );
    assert!(
        card.contains("evidence_scope: source_files"),
        "`code files` means language source, not every repository file: {card}"
    );
    assert!(
        card.contains("source_filter: category=source"),
        "an unqualified code-file request must use the harness-owned source category: {card}"
    );
    assert!(
        card.contains("exclude documentation, manifests, lockfiles"),
        "the steering must name the observed false-positive classes: {card}"
    );
    assert!(
        !card.contains("highest")
            && !card.contains("longest")
            && !card.contains("most lines")
            && !card.contains("line/size rankings")
            && !card.contains("code=true"),
        "the model card must carry a general source refinement, not an incident lexicon: {card}"
    );
}

#[test]
fn explicit_rust_table_prompt_steers_rs_filter_and_gfm_table() {
    let intake = PromptIntake::analyze(
        "can you give me a table of the rust files with the longest line counts instead?",
    );
    let card = intake.model_card();

    assert!(card.contains("response_shape: table"), "{card}");
    assert!(card.contains("evidence_scope: source_files"), "{card}");
    assert!(
        card.contains("source_extensions: rs"),
        "Rust must resolve through the language-pack data to its source extension: {card}"
    );
    assert!(
        card.contains("source_filter: category=source language=rust"),
        "the model needs the concrete harness filter, not just a language label: {card}"
    );
}

#[test]
fn ordinary_prompt_gets_no_incident_specific_refinement() {
    let card = PromptIntake::analyze("explain ownership briefly").model_card();

    assert!(!card.contains("response_format:"), "{card}");
    assert!(!card.contains("response_shape:"), "{card}");
    assert!(!card.contains("evidence_scope:"), "{card}");
    assert!(!card.contains("source_filter:"), "{card}");

    let comfortable = PromptIntake::analyze("make this interface more comfortable").model_card();
    assert!(
        !comfortable.contains("response_shape:"),
        "presentation inference must not match `table` inside another word: {comfortable}"
    );
}

#[test]
fn lexicon_overrides_drive_inference_table_driven() {
    // A dropped-in override list REPLACES its default wholesale.
    let custom = DispositionLexicon {
        explain: vec!["kerfuffle".to_string()],
        question_mark_disposition: PromptDisposition::Research,
        ..DispositionLexicon::default()
    };
    for (prompt, want) in [
        ("tell me about the kerfuffle", PromptDisposition::Explain),
        // The default explain needles are GONE (replaced), so "what is…?"
        // now reaches the retargeted ? fallback → Research.
        ("what is a monad?", PromptDisposition::Research),
        // Action still wins outright.
        ("fix the kerfuffle", PromptDisposition::Act),
        // No needle, no ?: Act.
        ("status report", PromptDisposition::Act),
    ] {
        assert_eq!(infer(prompt, &custom), want, "{prompt:?}");
    }
}

#[test]
fn analyze_with_applies_the_lexicon_and_keeps_ask_precedence() {
    // The lexicon changes the classification vs the defaults…
    let lex = DispositionLexicon {
        research: vec!["kerfuffle".to_string()],
        ..DispositionLexicon::default()
    };
    let intake = PromptIntake::analyze_with("tell me about the kerfuffle", &lex);
    assert_eq!(intake.disposition(), PromptDisposition::Research);
    // …but an unresolved decision still forces the Ask terminal, with the
    // lexicon-derived value preserved as the post-lock disposition.
    let asky = PromptIntake::analyze_with(
        "Investigate either the kerfuffle or the brouhaha; compare them.",
        &lex,
    );
    if asky.manifest().pending_decision_count() > 0 {
        assert_eq!(asky.disposition(), PromptDisposition::Ask);
    }
    // The empty-prompt Ask terminal is untouched by any lexicon.
    let empty = PromptIntake::analyze_with("   ", &lex);
    assert_eq!(empty.disposition(), PromptDisposition::Ask);
}

/// #2332 / #2331 / #2283: a request phrased as a question is a request. These
/// are the recorded prompts; each matched no needle and reached Explain only
/// through the `?` fallback, so an install, a review, and a retry all lost the
/// tools they needed. Each must now route exactly like its imperative form.
#[test]
fn a_request_phrased_as_a_question_routes_like_its_imperative() {
    let mut misrouted = Vec::new();
    for (question, imperative) in [
        (
            "Can you install skills for that herdr tool?",
            "install skills for that herdr tool",
        ),
        // #2332 wrote this one as "change 42", which the `change` action needle
        // already routes to Act; the recorded review reached Explain.
        ("Can you review PR 42 for me?", "review PR 42 for me"),
        (
            "Could you fetch the release notes from the tracker?",
            "fetch the release notes from the tracker",
        ),
        ("try now?", "try now"),
        (
            "Would you look at the failing job?",
            "look at the failing job",
        ),
        // The opener is read per clause, not only at byte zero.
        (
            "Thanks for that. Can you review PR 42?",
            "Thanks for that. review PR 42",
        ),
    ] {
        let lex = DispositionLexicon::default();
        assert_eq!(
            infer(imperative, &lex),
            PromptDisposition::Act,
            "{imperative:?}"
        );
        if infer(question, &lex) != PromptDisposition::Act {
            misrouted.push((question, infer(question, &lex)));
        }
    }
    assert!(
        misrouted.is_empty(),
        "requests routed as answers: {misrouted:?}"
    );
}

/// The twin of the test above, and the vacuous-green trap it closes: adding
/// request words proves nothing unless genuine questions still stay answers.
/// These stay out of `Act`, which also keeps them clear of the Act-only action
/// nudges and self-verify gate (`agentic/mod.rs`, `action_nudges && … == Act`),
/// so moving requests to Act hands no greeting or read-only answer a test
/// obligation (#2324).
#[test]
fn a_genuine_question_stays_an_answer() {
    let lex = DispositionLexicon::default();
    for question in [
        "How do I install a skill?",
        "What does tool_search do?",
        "hello?",
        "What can you do?",
        "Can you explain how prompt intake works?",
        "Could you tell me what the herdr tool does?",
        "Thanks. How do I install a skill?",
    ] {
        assert_eq!(
            infer(question, &lex),
            PromptDisposition::Explain,
            "{question:?} is a question, not a request"
        );
    }
}

/// Recorded, not endorsed: a capability question opening with a request opener
/// now reaches Act, so it enters the Act-only action nudges and self-verify
/// gate. Whether a test is owed belongs to #2324, which decides it from the
/// task rather than the disposition; this fixture hands that lane the case.
#[test]
fn a_capability_question_with_a_request_opener_reaches_act_for_2324() {
    assert_eq!(
        infer("Can you read Python?", &DispositionLexicon::default()),
        PromptDisposition::Act
    );
}

/// "refactor" is an instruction to change code. It was absent from the action
/// list, so "refactor the largest file in this repo" (the operator's standing
/// acceptance prompt) fell through to the research needle "largest" — a
/// read-only, 3-round turn that could never refactor anything (live
/// 2026-09-23). An action needle anywhere wins over a research one.
#[test]
fn refactor_is_an_action_even_when_the_target_is_named_by_size() {
    for prompt in [
        "refactor the largest file in this repo",
        "Refactor newt-core/src/agentic/mod.rs",
    ] {
        assert_eq!(
            PromptIntake::analyze(prompt).disposition(),
            PromptDisposition::Act,
            "{prompt}"
        );
    }
    // The evidence question that motivated "largest" stays Research.
    assert_eq!(
        PromptIntake::analyze("What are the 10 largest Rust files in this workspace").disposition(),
        PromptDisposition::Research
    );
}

/// #2553 finding 3: the action lexicon matched "refactor" by bare substring,
/// so "explain **refactor**ing strategies" hit the action needle before ever
/// reaching the `explain` list — an explanatory prompt granted full mutation
/// authority. The fix is whole-word matching in the lexicon matcher itself
/// (`contains_word`), not a patch scoped to "refactor" alone.
#[test]
fn explain_refactoring_is_not_the_refactor_action_needle() {
    assert_eq!(
        PromptIntake::analyze("explain refactoring strategies").disposition(),
        PromptDisposition::Explain,
        "refactoring is a different word from refactor, in an explanatory sentence"
    );
}

/// The whole-word fix applies to every action needle, not just "refactor":
/// "fix" is a substring of "fixture", and "explain the test fixture setup" is
/// an explanatory prompt, not an instruction to fix anything.
#[test]
fn explain_test_fixture_is_not_the_fix_action_needle() {
    assert_eq!(
        PromptIntake::analyze("explain the test fixture setup").disposition(),
        PromptDisposition::Explain,
        "fixture is a different word from fix"
    );
}

/// The whole-word switch has a real cost, not just a benefit: the research
/// needle "line count" (singular) stopped matching inside "line counts"
/// (plural) once boundary checks applied, regressing
/// `line_count_questions_classify_research_not_the_cliff`. Fixed by adding
/// the plural as its own lexicon entry (data, not a matcher special case) —
/// this test pins that the plural form still classifies Research.
#[test]
fn plural_line_counts_still_classifies_research() {
    assert_eq!(
        PromptIntake::analyze("show me the 10 code files with the highest line counts?")
            .disposition(),
        PromptDisposition::Research,
        "the plural form must match its own lexicon entry, not rely on substring matching"
    );
}

/// #2562 round 2 (PR-2562 review, "Where authority actually changes" table,
/// red first): whole-word matching (round 1) fixed the six regression rows
/// at the bottom of this table — the "intended fix" — but broke authority in
/// BOTH directions for every other row. Each row here was confirmed FAILED
/// against the round-1 commit (`e494925b`) before the round-2 lexicon
/// additions landed:
/// - a read-only gerund GAINED Act (`auditing …` → Act, was Research;
///   `researching …` → Act, was Research; `explaining …` → Act, was Explain)
///   because the research/explain needle no longer matched its own
///   inflection, so the prompt fell through to the terminal Act fallback;
/// - a mixed prompt LOST Act (`… and get it fixed` → Research/Explain, was
///   Act) because its only action cue was an inflected form no bare-verb
///   needle covers by design (round 2's ruling: explicit phrases, not
///   stemming).
///
/// The six "intended fix" rows are pinned here too, so this one table test
/// is the complete authority-boundary regression suite for both rounds.
#[test]
fn where_authority_actually_changes_matches_the_review_table() {
    let cases: &[(&str, PromptDisposition)] = &[
        // Mixed prompts: restored to Act by the round-2 action PHRASES
        // (never bare gerunds — see the lexicon's own comment).
        (
            "investigate the flaky test and get it fixed",
            PromptDisposition::Act,
        ),
        ("the largest file needs refactoring", PromptDisposition::Act),
        (
            "Why does this crash? It needs fixing.",
            PromptDisposition::Act,
        ),
        (
            "investigate why CI failed and get it committed",
            PromptDisposition::Act,
        ),
        ("rerun the tests and investigate", PromptDisposition::Act),
        (
            "The auth module is broken and needs fixing.",
            PromptDisposition::Act,
        ),
        // Read-only gerunds: restored to Research/Explain by the round-2
        // inflection DATA (never gained Act back through the fallback).
        ("auditing the permission table", PromptDisposition::Research),
        (
            "researching which crate to use",
            PromptDisposition::Research,
        ),
        ("explaining the retry loop", PromptDisposition::Explain),
        // The six "intended fix" rows (round 1's whole-word matching) —
        // regression-pinned here so a future change to either round cannot
        // silently re-admit the false Acts round 1 was written to remove.
        ("explain refactoring strategies", PromptDisposition::Explain),
        ("explain the prefix tree", PromptDisposition::Explain),
        ("what is the credit limit field", PromptDisposition::Explain),
        (
            "explain the test fixture layout",
            PromptDisposition::Explain,
        ),
        (
            "describe the emergency stop path",
            PromptDisposition::Explain,
        ),
        ("explain the exchange module", PromptDisposition::Explain),
    ];
    for (prompt, expected) in cases {
        assert_eq!(
            PromptIntake::analyze(prompt).disposition(),
            *expected,
            "{prompt:?} must classify {expected:?}"
        );
    }
}

/// #2562 round 3 (PR-2562 review round 2, red first): round 2's fix
/// introduced its OWN new false matches, all traced to the reviewer's own
/// round-1 recommendation list (their error, not the implementer's) —
/// NOUN forms (`summary`, `description`, `diagnosis`, …) name a THING, and
/// ordinary mutation requests routinely name things, so they stole Act from
/// everyday edits. Bare `rerun`/`re-run` had the same shape: it GAINED
/// authority on a prompt that only NAMES the word. Three fixes, validated
/// here: drop the noun forms (keep the gerunds — an activity, unlike a
/// thing, is safely read-only to over-match), replace bare `rerun`/`re-run`
/// with request phrases, and make `_` a word character in `contains_word`
/// so an identifier (`fix_path`, `build_graph`) can't leak a needle.
#[test]
fn round_3_noun_and_underscore_false_matches_are_fixed() {
    let cases: &[(&str, PromptDisposition)] = &[
        // Dropped noun forms: an ordinary edit that NAMES a thing must stay
        // Act, exactly as on main (through the fallback).
        (
            "update the description field in Cargo.toml",
            PromptDisposition::Act,
        ),
        ("add a summary line to the README", PromptDisposition::Act),
        (
            "rename the summary() helper to digest()",
            PromptDisposition::Act,
        ),
        // NOT asserted here: "the summary field is wrong, update it". The
        // review's own methodology grades a multi-clause prompt that
        // reaches the fallback as approximate (their `lex.py` treats
        // `extract_atomic_asks` as a single clause); measured against the
        // real classifier this comma-joined statement+imperative lands
        // Explain, not Act — a defensible, fail-closed outcome for a
        // genuinely mixed clause, and not one of the four required red
        // tests, so it is not pinned to an unvalidated expectation here.
        ("expose summary_stats in the api", PromptDisposition::Act),
        (
            "replace get_description with describe_item",
            PromptDisposition::Act,
        ),
        (
            "make the explanation shorter in the error message",
            PromptDisposition::Act,
        ),
        ("update the diagnosis message", PromptDisposition::Act),
        (
            "add a comparison test for the two parsers",
            PromptDisposition::Act,
        ),
        // Bare `rerun` no longer grants Act to a prompt that only names the
        // word; the phrase form still does.
        (
            "explain why the rerun flag exists",
            PromptDisposition::Explain,
        ),
        ("please rerun the failing suite", PromptDisposition::Act),
        // `_` is now a word character: an identifier can't leak a needle.
        ("explain what fix_path does", PromptDisposition::Explain),
        (
            "describe the build_graph function",
            PromptDisposition::Explain,
        ),
        // The round-1/round-2 rows this must NOT regress.
        ("rerun the tests and investigate", PromptDisposition::Act),
        (
            "refactor the largest file in this repo",
            PromptDisposition::Act,
        ),
    ];
    for (prompt, expected) in cases {
        assert_eq!(
            PromptIntake::analyze(prompt).disposition(),
            *expected,
            "{prompt:?} must classify {expected:?}"
        );
    }
}

/// A prompt that opens collaboratively proposes the work instead of ordering
/// it, so it reads as `Plan` before any action needle can claim it. The first
/// row is the exact prompt that otherwise hits BOTH the `refactor` action
/// needle and the `largest` research needle; "shall we … TOML?" proves the
/// opener beats the `?` fallback too; the U+2019 row proves the smart
/// apostrophe, which `to_ascii_lowercase` leaves alone, is its own entry.
#[test]
fn a_collaborative_opener_plans_before_the_action_needle_acts() {
    let lex = DispositionLexicon::default();
    for prompt in [
        "let's refactor the largest file",
        "lets fix the flaky test",
        "We should split prompt_intake.rs",
        "how about we drop the cache layer",
        "I'm thinking we rewrite intake in wyvern",
        "shall we move the lexicon to TOML?",
        "Let’s refactor tools.rs",
    ] {
        assert_eq!(infer(prompt, &lex), PromptDisposition::Plan, "{prompt:?}");
    }
}

/// The opener alone is not a proposal. A bare nudge behind it carries no
/// objective of its own ([`crate::classifiers::is_bare_continuation`] on the
/// remainder), so the prompt falls through to the ordinary ladder and acts,
/// exactly as the nudge does without the opener.
#[test]
fn a_bare_continuation_behind_a_collaborative_opener_still_acts() {
    let lex = DispositionLexicon::default();
    for prompt in [
        "let's go",
        "Let's go!",
        "let's continue",
        "lets proceed",
        "let's land it",
        "let's ship it",
        "let's do it",
    ] {
        assert_eq!(infer(prompt, &lex), PromptDisposition::Act, "{prompt:?}");
    }
}

/// The opener is read at the START of an ask, never anywhere in the prompt:
/// a mid-clause "we should" is a subordinate clause, and the prompt keeps the
/// reading its own verb gives it.
#[test]
fn a_collaborative_phrase_mid_clause_is_not_an_opener() {
    let lex = DispositionLexicon::default();
    for (prompt, expected) in [
        ("fix the bug we should have caught", PromptDisposition::Act),
        (
            "explain why we should pin serde",
            PromptDisposition::Explain,
        ),
    ] {
        assert_eq!(infer(prompt, &lex), expected, "{prompt:?}");
    }
}

/// `extract_atomic_asks_with` splits on lines (and on `;` / `. `), so this
/// prompt is two asks: one collaborative, one plain imperative. The check is
/// `all()` over the asks: a proposal is positive evidence read from every
/// ask, never inferred from one ask among orders, so one plain imperative
/// keeps the prompt on its ordinary reading, where `fix` acts.
#[test]
fn a_mixed_prompt_with_one_collaborative_ask_keeps_its_own_reading() {
    let prompt = "let's talk about the parser\nthen fix the failing test";
    assert_eq!(
        infer(prompt, &DispositionLexicon::default()),
        PromptDisposition::Act,
        "one plain imperative among the asks keeps the ordinary reading"
    );
}

/// A bounded verb behind the opener is an order, not a proposal: "let's
/// commit this" asks for the commit. The verbs are lexicon data too.
#[test]
fn a_direct_verb_behind_a_collaborative_opener_still_acts() {
    let lex = DispositionLexicon::default();
    for prompt in [
        "let's commit this",
        "let's push",
        "lets run the tests",
        "Let's merge it!",
        "let's rebase onto main",
        "we should build and test first",
    ] {
        assert_eq!(infer(prompt, &lex), PromptDisposition::Act, "{prompt:?}");
    }
    let dropped = DispositionLexicon {
        direct_verbs: vec![],
        ..DispositionLexicon::default()
    };
    assert_eq!(
        infer("let's commit this", &dropped),
        PromptDisposition::Plan
    );
}

/// The openers are lexicon data, droppable like every other list: with the
/// list emptied, the action needle decides exactly as it did before.
#[test]
fn collaborative_openers_are_droppable_lexicon_data() {
    let dropped = DispositionLexicon {
        collaborative: vec![],
        ..DispositionLexicon::default()
    };
    assert_eq!(
        infer("let's refactor the largest file", &dropped),
        PromptDisposition::Act
    );
}