saya-cli 0.4.1

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
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
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
use super::super::session_facts::{SESSION_FACTS_HEADING, SessionFacts, session_facts_text};
use super::super::turn_context::LAST_SQL_BLOCK_LABEL;
use super::*;
use crate::connection::{ConnectionEntry, ConnectionRegistry};
use async_trait::async_trait;
use saya_agent::{build_messages, turn_bytes};
use saya_types::{ConnectionError, QueryRequest, QueryResult, SchemaTree, SqlDialect};
use std::path::PathBuf;

struct DummyConnector {
    dialect: SqlDialect,
}

#[async_trait]
impl saya_connectors::DatabaseConnector for DummyConnector {
    fn dialect(&self) -> SqlDialect {
        self.dialect
    }
    async fn connect(&self) -> Result<(), ConnectionError> {
        Ok(())
    }
    async fn schema(&self) -> Result<SchemaTree, ConnectionError> {
        Ok(SchemaTree::default())
    }
    async fn execute(&self, req: QueryRequest) -> Result<QueryResult, ConnectionError> {
        Ok(QueryResult::empty(req.sql))
    }
}

fn single_registry(name: &str) -> ConnectionRegistry {
    let mut reg = ConnectionRegistry::new(name);
    reg.insert(
        name,
        ConnectionEntry {
            connector: Box::new(DummyConnector {
                dialect: SqlDialect::Postgres,
            }),
            dialect: SqlDialect::Postgres,
            profile_id: None,
        },
    );
    reg
}

fn multi_registry() -> ConnectionRegistry {
    let mut reg = ConnectionRegistry::new("db1");
    reg.insert(
        "db1",
        ConnectionEntry {
            connector: Box::new(DummyConnector {
                dialect: SqlDialect::Postgres,
            }),
            dialect: SqlDialect::Postgres,
            profile_id: None,
        },
    );
    reg.insert(
        "db2",
        ConnectionEntry {
            connector: Box::new(DummyConnector {
                dialect: SqlDialect::Mysql,
            }),
            dialect: SqlDialect::Mysql,
            profile_id: None,
        },
    );
    reg
}

#[test]
fn memory_section_appears_under_assisted_and_absent_under_off() {
    assert!(memory_section(MemoryMode::Assisted).is_some());
    assert!(memory_section(MemoryMode::Off).is_none());

    let text = memory_section(MemoryMode::Assisted).unwrap();
    // Briefing contents verification
    assert!(text.contains("durable knowledge"));
    assert!(text.contains("Confirmed facts relevant to the question are already supplied"));
    assert!(text.contains("contract_search"));
    assert!(text.contains("contract_read"));
    assert!(text.contains("restate it explicitly and precisely in the answer"));
    // The naming rule is not here: its correct wording depends on the engine,
    // so it is built per connection alongside this section.
    assert!(!text.contains("catalog.schema.object"));
}

#[test]
fn assemble_system_prompt_single_connection_off() {
    let reg = single_registry("main");
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, true, &facts(&reg, &None))
            .expect("a prompt");
    // With one connection and memory off there is no context and no briefing —
    // but the model is still shown a catalog/schema/table tree by schema
    // discovery, so it still has to be told what SQL will accept.
    assert!(!prompt.contains("durable knowledge"));
    assert!(prompt.contains("catalog.schema.object"));
}

#[test]
fn assemble_system_prompt_single_connection_assisted() {
    let reg = single_registry("main");
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Assisted, true, &facts(&reg, &None))
            .expect("a prompt");
    assert!(prompt.starts_with(MEMORY_SYSTEM_PROMPT));
    // A single PostgreSQL connection: the memory briefing, then its naming rule.
    assert!(prompt.contains("catalog.schema.object"));
}

/// The previous turn's SQL no longer reaches the system prompt; it rides the
/// user turn as a context block (see [`last_sql_hint_reaches_user_turn`]). With
/// memory off, the system prompt carries no hint and no SQL text.
#[test]
fn assemble_system_prompt_with_last_sql_and_memory_off() {
    let reg = single_registry("main");
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, true, &facts(&reg, &None))
            .expect("a prompt");
    assert!(!prompt.contains("durable knowledge"));
    assert!(
        !prompt.contains("most recent SQL you ran was"),
        "the hint must not appear in the system prompt: {prompt}"
    );
}

/// The hint is absent from the system prompt under assisted memory too — the
/// hint block is built separately and attached to the user turn.
#[test]
fn assemble_system_prompt_with_last_sql_and_assisted() {
    let reg = single_registry("main");
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Assisted, true, &facts(&reg, &None))
            .expect("a prompt");
    assert!(prompt.contains(MEMORY_SYSTEM_PROMPT));
    assert!(
        !prompt.contains("most recent SQL you ran was"),
        "the hint must not appear in the system prompt: {prompt}"
    );
}

/// The multi-connection system prompt keeps its section order (connections,
/// memory, guidance) and never carries the previous SQL.
#[test]
fn assemble_system_prompt_multi_connection_assisted_and_sql() {
    let reg = multi_registry();
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Assisted, true, &facts(&reg, &None))
            .expect("a prompt");

    // Contains all three sections in expected order.
    let conn_idx = prompt.find("Available database connections").unwrap();
    let mem_idx = prompt.find("SAYA maintains durable knowledge").unwrap();
    let guidance_idx = prompt.find("Multi-step work is expected").unwrap();

    assert!(conn_idx < mem_idx);
    assert!(mem_idx < guidance_idx);
    assert!(
        !prompt.contains("most recent SQL you ran was"),
        "the hint must not appear in the system prompt: {prompt}"
    );
    // The dialect statement rides every request, multi-connection included: the
    // engines are listed, then the dialect is pinned whatever the DDL declares.
    assert!(
        prompt.contains("The SQL dialect is the connected engine's, whatever the DDL says"),
        "the dialect statement must appear on every request: {prompt}"
    );
    assert!(
        prompt.contains("- postgresql: `catalog.schema.object`"),
        "the multi-connection list must name each engine: {prompt}"
    );
}

#[test]
fn assemble_system_prompt_stays_within_budget() {
    let reg = multi_registry();
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Assisted, true, &facts(&reg, &None));
    assert!(prompt.is_some());
    let system_text = prompt.unwrap();

    // Check raw length is tiny compared to a conversation budget.
    assert!(system_text.len() < 4000);

    // The system prompt plus an ordinary question is far below any realistic
    // context_byte_budget — the loop bounds the conversation, not a start-of-run
    // cap, so this is a sanity check, not an enforced ceiling.
    let bytes = turn_bytes(Some(&system_text), &[], "How many orders were placed?");
    assert!(bytes < 32 * 1024);
}

/// Assisted, but the state store did not open or the privacy gate is shut: the
/// section promises supplied facts and two tools that this turn does not have,
/// so it must not appear.
#[test]
fn memory_section_is_absent_when_memory_is_configured_on_but_unreachable() {
    let reg = single_registry("main");
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Assisted, false, &facts(&reg, &None))
            .expect("a prompt");
    assert!(
        !prompt.contains("durable knowledge"),
        "memory is unreachable, so the briefing must not claim otherwise: {prompt}"
    );

    assert!(memory_reachable(true, true));
    assert!(
        !memory_reachable(false, true),
        "no state store, no briefing"
    );
    assert!(
        !memory_reachable(true, false),
        "privacy gate shut, no briefing"
    );
}

fn registry_with(dialect: SqlDialect) -> ConnectionRegistry {
    let mut reg = ConnectionRegistry::new("db");
    reg.insert(
        "db",
        ConnectionEntry {
            connector: Box::new(DummyConnector { dialect }),
            dialect,
            profile_id: None,
        },
    );
    reg
}

/// SQLite has no catalog and no schema, so a three-part name is a syntax error
/// there. Telling the model to write one regardless costs a rejected query and
/// a wasted round trip on nearly every question before it corrects itself.
#[test]
fn the_naming_rule_matches_what_the_engine_accepts() {
    let sqlite_reg = registry_with(SqlDialect::Sqlite);
    let sqlite = assemble_system_prompt_with_session(
        &sqlite_reg,
        MemoryMode::Assisted,
        true,
        &facts(&sqlite_reg, &None),
    )
    .expect("a prompt");
    assert!(
        !sqlite.contains("catalog.schema.object"),
        "SQLite cannot parse a three-part name, so the prompt must not ask for one: {sqlite}"
    );

    let postgres_reg = registry_with(SqlDialect::Postgres);
    let postgres = assemble_system_prompt_with_session(
        &postgres_reg,
        MemoryMode::Assisted,
        true,
        &facts(&postgres_reg, &None),
    )
    .expect("a prompt");
    assert!(
        postgres.contains("catalog.schema.object"),
        "PostgreSQL does accept the three-part name and should still be asked for it: {postgres}"
    );
}

/// The rule exists so a remembered fact binds to a real object, and that need
/// does not go away on an engine with fewer name parts — the prompt must still
/// ask for the fullest name the engine has.
#[test]
fn every_engine_is_still_told_to_qualify_names() {
    for dialect in [
        SqlDialect::Postgres,
        SqlDialect::Mysql,
        SqlDialect::Sqlite,
        SqlDialect::DuckDb,
        SqlDialect::Snowflake,
    ] {
        let prompt = assemble_system_prompt_with_session(
            &registry_with(dialect),
            MemoryMode::Assisted,
            true,
            &facts(&registry_with(dialect), &None),
        )
        .expect("a prompt");
        assert!(
            prompt.contains(dialect.qualified_name_form()),
            "{} must be told its own name form: {prompt}",
            dialect.as_str()
        );
    }
}

/// Writing a name the engine can parse is not a memory concern: schema
/// discovery shows the same catalog/schema/table tree whether memory is on or
/// off, so the rule that keeps SQL valid has to be present either way.
#[test]
fn the_naming_rule_is_present_with_memory_off() {
    let prompt = assemble_system_prompt_with_session(
        &registry_with(SqlDialect::Sqlite),
        MemoryMode::Off,
        false,
        &facts(&registry_with(SqlDialect::Sqlite), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains(SqlDialect::Sqlite.qualified_name_form()),
        "the SQL naming rule must survive memory being off: {prompt}"
    );
    assert!(
        !prompt.contains("durable knowledge"),
        "memory is off, so the memory briefing must stay absent: {prompt}"
    );
}

/// A single connection is never named by `describe_context` (it stays silent
/// for one connection), so the prompt itself must name the engine — the model
/// is otherwise never told it is writing SQLite. The prompt must also coach
/// multi-step work, refusing to repeat a failed attempt, and giving up with a
/// reason when the question cannot be answered from what the session offers.
#[test]
fn single_connection_prompt_names_engine_and_guides_giving_up() {
    let prompt = assemble_system_prompt_with_session(
        &registry_with(SqlDialect::Postgres),
        MemoryMode::Off,
        false,
        &facts(&registry_with(SqlDialect::Postgres), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains("postgresql"),
        "the engine must be named for a single connection: {prompt}"
    );
    assert!(
        prompt.contains("Multi-step work is expected"),
        "the prompt must say multi-step work is expected: {prompt}"
    );
    assert!(
        prompt.contains("Do not repeat an attempt that already failed"),
        "the prompt must tell the model not to repeat a failed attempt: {prompt}"
    );
    assert!(
        prompt.contains("Giving up with a reason"),
        "the prompt must sanction giving up with a reason: {prompt}"
    );
    assert!(
        prompt.contains("stop and say so"),
        "the prompt must tell the model to stop and explain when it cannot answer: {prompt}"
    );
}

/// The give-up guidance is not a single-connection concern: with several
/// databases the model can still hit a question no connected database can
/// answer, so the coaching must be present there too.
#[test]
fn multi_connection_prompt_also_guides_giving_up() {
    let prompt = assemble_system_prompt_with_session(
        &multi_registry(),
        MemoryMode::Off,
        false,
        &facts(&multi_registry(), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains("Giving up with a reason"),
        "multi-connection prompt must also coach giving up: {prompt}"
    );
}

/// The single largest class of benchmark failures was the right numbers in the
/// wrong presentation, so the assembled prompt must brief the model on the
/// shape of an answer. The contract is a section in its own right, pushed
/// unconditionally alongside [`WORKING_GUIDANCE`].
#[test]
fn assembled_prompt_contains_the_answer_contract() {
    let prompt = assemble_system_prompt_with_session(
        &single_registry("main"),
        MemoryMode::Off,
        false,
        &facts(&single_registry("main"), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains(ANSWER_CONTRACT),
        "the answer contract must be part of every prompt: {prompt}"
    );
}

/// The contract governs how the model writes any answer, so it is neither a
/// memory concern nor a multi-connection concern: it must appear in every
/// assembled prompt regardless of how many databases are connected or whether
/// memory is on, reachable, or off.
#[test]
fn answer_contract_present_regardless_of_connections_and_memory() {
    let cases: [(ConnectionRegistry, MemoryMode, bool); 6] = [
        (single_registry("main"), MemoryMode::Off, false),
        (single_registry("main"), MemoryMode::Assisted, true),
        (single_registry("main"), MemoryMode::Assisted, false),
        (multi_registry(), MemoryMode::Off, false),
        (multi_registry(), MemoryMode::Assisted, true),
        (multi_registry(), MemoryMode::Off, true),
    ];
    for (reg, mode, reachable) in cases {
        let prompt =
            assemble_system_prompt_with_session(&reg, mode, reachable, &facts(&reg, &None))
                .expect("a prompt for this case");
        assert!(
            prompt.contains(ANSWER_CONTRACT),
            "answer contract missing for memory {}, reachable {reachable}: {prompt}",
            mode.as_str(),
        );
    }
}

/// Stated ceiling on the answer-contract section. The contract rides on every
/// request, so its length is a real cost; this number keeps the section from
/// growing unbounded later. A new clause that crosses it must either tighten
/// the wording or raise the ceiling deliberately.
const ANSWER_CONTRACT_MAX_BYTES: usize = 1200;

/// A measured class of benchmark failure: the agent gathers the operands of
/// the computation a question asks for and stops, leaving the division or the
/// subtraction to the reader. A question asking for a ratio or a percentage
/// wants the quotient, not the two numbers it divides. Pinning the contract's
/// compute directive here, not just in [`ANSWER_CONTRACT`], because the
/// assembled prompt is what the model actually reads.
#[test]
fn answer_contract_directs_completing_a_computation_not_returning_its_operands() {
    let prompt = assemble_system_prompt_with_session(
        &single_registry("main"),
        MemoryMode::Off,
        false,
        &facts(&single_registry("main"), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains("a ratio, a percentage, or a difference"),
        "the directive must name the class of computations it covers: {prompt}"
    );
    assert!(
        prompt.contains("compute that value and answer with it"),
        "the contract must tell the model to finish the computation the question names: {prompt}"
    );
    // The directive targets the failure, not a query shape: the failures were
    // correct operands handed back with the arithmetic left undone.
    assert!(
        prompt.contains("returning the operands alone stops one step short"),
        "the directive must name the failure it fixes — stopping one step short: {prompt}"
    );
}

/// A superlative names one thing. "Which driver had the fastest lap" has a
/// single-row answer; handing back every driver ordered by lap time makes the
/// reader find the answer the model already knew. The superlative clause is
/// the same rule as the computation clause looked at from the other side: both
/// are the agent stopping short of the value asked for.
#[test]
fn answer_contract_directs_a_superlative_to_name_one_row() {
    let prompt = assemble_system_prompt_with_session(
        &single_registry("main"),
        MemoryMode::Off,
        false,
        &facts(&single_registry("main"), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains("\"the fastest\", \"the highest\", \"the top one\""),
        "the directive must carry the examples the earlier contract was measured on: {prompt}"
    );
    // A superlative runs in both directions; "the fewest" keeps the rule from
    // being read as a maximum only.
    assert!(
        prompt.contains("\"the fewest\""),
        "the directive must cover a superlative in the other direction: {prompt}"
    );
    assert!(
        prompt.contains("asks which one: answer with that row"),
        "the contract must tell the model a superlative has a one-row answer: {prompt}"
    );
    // Ties are governed by the clause above: a superlative must not be read as
    // permission to drop rows to manufacture a single answer.
    assert!(
        prompt.contains("Every row tied with it is part of the answer"),
        "the one-row directive must not license dropping tied rows: {prompt}"
    );
}

#[test]
fn answer_contract_section_stays_under_documented_ceiling() {
    assert!(
        ANSWER_CONTRACT.len() <= ANSWER_CONTRACT_MAX_BYTES,
        "answer contract is {} bytes; the stated ceiling is {}",
        ANSWER_CONTRACT.len(),
        ANSWER_CONTRACT_MAX_BYTES,
    );
}

// --- prompt-cache prefix: the system block must be session-stable ----------
//
// A prompt cache is a *prefix* cache: providers reuse the longest leading run
// of tokens they have seen before. The previous turn's SQL was appended to the
// system prompt, so every follow-up that ran SQL changed the system block and
// forfeited the cached prefix. These tests pin the fix: the hint moves onto the
// user turn, and the system block stops varying with the previous SQL.

/// The assembled system prompt must not contain the previous turn's SQL, for
/// any input — the hint now lives on the user turn, so the system block is the
/// same whether or not a query just ran. The session-aware assembly takes no SQL,
/// so the hint prose can never reach it; this locks that property.
#[test]
fn system_prompt_does_not_contain_the_previous_sql() {
    let reg = single_registry("main");
    let prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, true, &facts(&reg, &None))
            .expect("a prompt");
    assert!(
        !prompt.contains("most recent SQL you ran was"),
        "the SQL hint prose must not appear in the system prompt: {prompt}"
    );
    // The hint block carries the SQL; the system prompt never sees it.
    let hint = last_sql_hint_block("SELECT 1 FROM tbl").expect("a hint block");
    assert!(
        !prompt.contains(&hint.body),
        "the system prompt must not contain the hint block body: {prompt}"
    );
}

/// Two turns that differ only in the previous SQL must produce byte-identical
/// system *messages* (the constant SAYA prompt plus the assembled extra). This
/// is the property the prefix cache depends on, asserted directly through the
/// same `build_messages` the runtime uses, rather than inferred from the hint's
/// absence.
#[test]
fn system_message_is_byte_identical_across_turns_differing_only_in_last_sql() {
    let reg = single_registry("main");
    let system_prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, true, &facts(&reg, &None));
    let question = "refine the last query";
    // Turn A: no previous SQL. Turn B: a previous SQL hint on the user turn.
    let blocks_a: Vec<saya_agent::ContextBlock> = Vec::new();
    let blocks_b = vec![last_sql_hint_block("SELECT 1 FROM tbl").expect("a hint block")];
    let msgs_a = build_messages(
        system_prompt.as_deref(),
        &blocks_a,
        question,
        &[],
        32 * 1024,
    )
    .unwrap();
    let msgs_b = build_messages(
        system_prompt.as_deref(),
        &blocks_b,
        question,
        &[],
        32 * 1024,
    )
    .unwrap();
    assert_eq!(
        msgs_a[0], msgs_b[0],
        "the system message must not vary with the previous turn's SQL — that is what makes the prefix cache work"
    );
    // Sanity: the user turns do differ (B carries the hint).
    assert_ne!(msgs_a[1], msgs_b[1]);
}

/// The dialect statement must be present and name the connected engine: one
/// SQLite database was a PostgreSQL dump whose DDL still declared `jsonb` and
/// `point`, so the model wrote Postgres syntax SQLite rejected. The prompt must
/// say plainly that the SQL dialect is the connected engine's, whatever the DDL
/// declares.
#[test]
fn dialect_statement_names_the_connected_engine() {
    let prompt = assemble_system_prompt_with_session(
        &registry_with(SqlDialect::Sqlite),
        MemoryMode::Off,
        false,
        &facts(&registry_with(SqlDialect::Sqlite), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains("The SQL dialect is sqlite's, whatever the DDL says"),
        "the dialect statement must name the connected engine: {prompt}"
    );
    assert!(
        prompt.contains("declared column types in this database may come from another engine"),
        "the prompt must warn that declared types may come from another engine: {prompt}"
    );
}

/// The hint still reaches the model — on the user turn, never the system
/// message. The block is untrusted data rendered into the user turn by
/// `build_messages`, so the SQL appears beside the question and the system
/// message stays invariant.
#[test]
fn last_sql_hint_reaches_user_turn_not_system_message() {
    let reg = single_registry("main");
    let system_prompt =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, true, &facts(&reg, &None));
    let hint = last_sql_hint_block("SELECT 1 FROM tbl").expect("a hint block");
    let messages = build_messages(
        system_prompt.as_deref(),
        &[hint],
        "refine it",
        &[],
        32 * 1024,
    )
    .unwrap();
    // The system message is invariant: no hint, no SQL.
    assert_eq!(messages[0].role, "system");
    assert!(
        !messages[0].content.contains("SELECT 1 FROM tbl"),
        "the hint must not leak into the system message: {messages:?}"
    );
    assert!(
        !messages[0].content.contains("most recent SQL you ran was"),
        "the hint prose must not leak into the system message: {messages:?}"
    );
    // The user turn carries the hint, labelled and escaped, before the question.
    assert_eq!(messages[1].role, "user");
    assert!(messages[1].content.contains("SELECT 1 FROM tbl"));
    assert!(messages[1].content.contains("most recent SQL you ran was"));
    assert!(
        messages[1].content.ends_with("refine it"),
        "the user's question must still trail the block: {messages:?}"
    );
}

/// The hint block is `None` for empty/whitespace SQL, so a turn with no prior
/// query adds no block and the user turn is byte-identical to one with no hint.
#[test]
fn last_sql_hint_block_is_none_for_empty_sql() {
    assert!(last_sql_hint_block("").is_none());
    assert!(last_sql_hint_block("   \n\t ").is_none());
    let hint = last_sql_hint_block("SELECT 1").expect("non-empty SQL yields a block");
    assert_eq!(hint.label, LAST_SQL_BLOCK_LABEL);
    assert!(hint.body.contains("SELECT 1"));
    assert!(hint.body.contains("most recent SQL you ran was"));
    assert!(!hint.truncated);
}

// --- mode slice 5: what the model is told under Plan -----------------------
//
// Plan is enforced (hidden definitions, engine Deny, derived permits off),
// advertised (absent from the tool list), reachable (`/mode`), visible
// (the status segment) and durable — but the model was never told what Plan
// means, so it could promise edits it cannot make. The fix is one appended
// paragraph under Plan only. These tests pin: Build byte-identical to before,
// Plan carrying the paragraph exactly once, and the append being the only
// difference.

use saya_agent::AgentMode;

/// The Plan paragraph's bytes cannot drift unnoticed: pinned here and in
/// `assemble_system_prompt_for_mode`.
#[test]
fn plan_paragraph_bytes_are_pinned() {
    assert_eq!(
        PLAN_SYSTEM_PROMPT,
        "You are in Plan mode: investigate and answer with a plan. \
        Write-shaped tools are absent from your tool list and would refuse if called; \
        do not promise edits as if you had made them — describe the change you would make instead. \
        Plan composes with the approval policy and never widens it."
    );
}

/// Under Build the mode-aware prompt is byte-identical to the unmoded
/// session-aware prompt for the same session inputs — the append-only Plan
/// difference, asserted directly on the session-aware entry points.
#[test]
fn build_prompt_is_byte_identical_to_the_unmoded_prompt() {
    for (reg, mode, reachable) in [
        (single_registry("main"), MemoryMode::Off, false),
        (single_registry("main"), MemoryMode::Assisted, true),
        (single_registry("main"), MemoryMode::Assisted, false),
        (multi_registry(), MemoryMode::Off, false),
        (multi_registry(), MemoryMode::Assisted, true),
        (multi_registry(), MemoryMode::Off, true),
    ] {
        assert_eq!(
            assemble_system_prompt_for_mode(
                &reg,
                mode,
                reachable,
                &facts(&reg, &None),
                AgentMode::Build
            ),
            assemble_system_prompt_with_session(&reg, mode, reachable, &facts(&reg, &None)),
            "Build must be byte-identical to the unmoded prompt",
        );
    }
}

/// Under Plan the paragraph appears exactly once, and appending it is the
/// only difference from Build.
#[test]
fn plan_prompt_appends_the_paragraph_exactly_once_and_nothing_else() {
    for (reg, mode, reachable) in [
        (single_registry("main"), MemoryMode::Off, false),
        (single_registry("main"), MemoryMode::Assisted, true),
        (multi_registry(), MemoryMode::Off, true),
    ] {
        let build = assemble_system_prompt_for_mode(
            &reg,
            mode,
            reachable,
            &facts(&reg, &None),
            AgentMode::Build,
        )
        .expect("a prompt");
        let plan = assemble_system_prompt_for_mode(
            &reg,
            mode,
            reachable,
            &facts(&reg, &None),
            AgentMode::Plan,
        )
        .expect("a prompt");
        assert_eq!(
            plan.matches(PLAN_SYSTEM_PROMPT).count(),
            1,
            "the paragraph must appear exactly once: {plan}"
        );
        assert_eq!(
            plan,
            format!("{build}\n\n{PLAN_SYSTEM_PROMPT}"),
            "appending the paragraph must be the only difference",
        );
    }
}

/// Pin: every `ANSWER_CONTRACT` bullet is byte-identical to `release/0.4.1`.
/// Each clause fixes a measured class of benchmark failure; rewording risks
/// regressions for zero truth gain, so a future tidy-up must trip here first.
/// Only the header and the query-results scoping line may differ.
#[test]
fn answer_contract_bullets_are_byte_identical_to_release_0_4_1() {
    let bullets = [
        "- Return only the columns the question asks for; drop intermediate working columns.",
        "- Do not round unless asked.",
        "- Write dates as ISO YYYY-MM-DD.",
        "- If the question asks for a ratio, a percentage, or a difference, compute that value and answer with it — returning the operands alone stops one step short.",
        "- A superlative — \"the fastest\", \"the highest\", \"the top one\", \"the fewest\" — asks which one: answer with that row and the value that makes it so, not the ranking it came from. Every row tied with it is part of the answer.",
        "- Answer every quantity the question names; if it asks for two things, answer both.",
        "- Read measure words literally: \"volume\" is units, \"revenue\" is money.",
        "- A qualifier on a metric is not a qualifier on the population — filter the metric, not the rows.",
        "- Keep every row tied at a cut-off; never drop a tie to fit a limit.",
        "- When a period is named, enumerate that whole period, not only the rows that happen to appear in the data.",
    ];
    for bullet in bullets {
        assert!(
            ANSWER_CONTRACT.contains(bullet),
            "contract bullet changed or missing: {bullet}\n{ANSWER_CONTRACT}"
        );
    }
    assert_eq!(
        ANSWER_CONTRACT
            .lines()
            .filter(|line| line.starts_with("- "))
            .count(),
        10,
        "no bullet may be added or removed without tripping this pin: {ANSWER_CONTRACT}"
    );
}

/// The column-selection rules are nonsense for a turn whose answer is "I wrote
/// the file". The contract carries one scoping line limiting it to answers
/// that report query results; every other answer is untouched.
#[test]
fn answer_contract_scopes_itself_to_query_results() {
    assert!(
        ANSWER_CONTRACT.contains("govern answers that report query results"),
        "the contract must scope itself to query-result answers: {ANSWER_CONTRACT}"
    );
    assert!(
        ANSWER_CONTRACT.contains("leave other answers untouched"),
        "the contract must leave non-query answers alone: {ANSWER_CONTRACT}"
    );
}

/// A workspace-only session has no database, so the assembled prompt must not
/// assert "from this database" as the only place an answer can come from. The
/// stopping rule covers whatever the session is actually working with.
#[test]
fn working_guidance_does_not_scope_answers_to_the_database() {
    let prompt = assemble_system_prompt_with_session(
        &single_registry("main"),
        MemoryMode::Off,
        false,
        &facts(&single_registry("main"), &None),
    )
    .expect("a prompt");
    assert!(
        !prompt.contains("from this database"),
        "the stopping rule must cover the whole session, not just a database: {prompt}"
    );
}

/// The schema-discovery advice is genuinely database counsel and stays — as
/// the database case — in the assembled prompt of a database session.
#[test]
fn database_session_prompt_keeps_schema_discovery_advice() {
    let prompt = assemble_system_prompt_with_session(
        &single_registry("main"),
        MemoryMode::Off,
        false,
        &facts(&single_registry("main"), &None),
    )
    .expect("a prompt");
    assert!(
        prompt.contains("discover the schema before you query it"),
        "a database session must keep the schema-discovery advice: {prompt}"
    );
    assert!(
        prompt.contains("a missing table or column"),
        "a database session must keep the missing-table specifics: {prompt}"
    );
}

/// The paragraph says what Plan means and how it is enforced, without
/// overclaiming: it names Plan mode, the absent-then-refusing tools, the
/// no-promised-edits rule, and the composition with the approval policy —
/// and it never claims reads are unrestricted.
#[test]
fn plan_paragraph_says_the_mechanism_without_overclaiming_reads() {
    let reg = single_registry("main");
    let plan = assemble_system_prompt_for_mode(
        &reg,
        MemoryMode::Off,
        false,
        &facts(&reg, &None),
        AgentMode::Plan,
    )
    .expect("a prompt");
    assert!(
        plan.contains("You are in Plan mode: investigate and answer with a plan"),
        "must say the session is in Plan mode: {plan}"
    );
    assert!(
        plan.contains("absent from your tool list and would refuse if called"),
        "must state the honest mechanism — absent, and refusing if called: {plan}"
    );
    assert!(
        plan.contains("describe the change you would make instead"),
        "must tell it to describe the change, not promise made edits: {plan}"
    );
    assert!(
        plan.contains("Plan composes with the approval policy and never widens it"),
        "must state Plan composes with the policy: {plan}"
    );
    assert!(
        !plan.contains("unrestricted"),
        "must not claim reads are unrestricted: {plan}"
    );
}

/// Slice 3 — session facts: what the session is, not what it may do.
///
/// The system prompt must orient the model — which connections are in scope,
/// whether a workspace root is bound — without restating the tool list. The
/// facts are session-stable inputs (the connection set, the bound root), so
/// the section keeps one prefix-cache key across turns. Approval mode is out:
/// it can change mid-session via `/approvals`, which would churn the key.
fn facts<'a>(reg: &'a ConnectionRegistry, root: &'a Option<PathBuf>) -> SessionFacts<'a> {
    SessionFacts {
        registry: reg,
        workspace_root: root.as_deref(),
    }
}

/// A database-only session names its connection and says no workspace is
/// bound — the model must not assume files it cannot see.
#[test]
fn database_only_session_names_connections_and_no_workspace() {
    let reg = single_registry("main");
    let text = session_facts_text(&facts(&reg, &None)).expect("a database session has facts");
    assert!(
        text.contains("main"),
        "the facts must name the connection in scope: {text}"
    );
    assert!(
        text.contains("No workspace is bound"),
        "the facts must say no workspace is bound: {text}"
    );
}

/// A workspace-only session names the root and does not claim a database.
#[test]
fn workspace_only_session_names_root_and_claims_no_database() {
    let empty = ConnectionRegistry::new("main");
    let root = Some(PathBuf::from("/repo"));
    let text = session_facts_text(&facts(&empty, &root)).expect("a workspace session has facts");
    assert!(
        text.contains("/repo"),
        "the facts must name the bound root: {text}"
    );
    assert!(
        text.contains("No database is connected"),
        "the facts must not imply a database is in scope: {text}"
    );
}

/// Both bound: both are named.
#[test]
fn session_with_both_names_both() {
    let reg = multi_registry();
    let root = Some(PathBuf::from("/repo"));
    let text = session_facts_text(&facts(&reg, &root)).expect("a session with both has facts");
    assert!(text.contains("db1"), "both connections named: {text}");
    assert!(text.contains("db2"), "both connections named: {text}");
    assert!(text.contains("/repo"), "the root named: {text}");
}

/// Neither bound: no facts section at all — no empty heading. The legacy
/// no-root entry point is the caller here, so `assemble_system_prompt` keeps
/// a direct production-shaped caller alongside the session-aware paths.
#[test]
fn session_with_neither_produces_no_facts_section() {
    let empty = ConnectionRegistry::new("main");
    assert!(session_facts_text(&facts(&empty, &None)).is_none());
    let prompt = assemble_system_prompt(&empty, MemoryMode::Off, false);
    assert!(
        !prompt
            .as_deref()
            .unwrap_or("")
            .contains(SESSION_FACTS_HEADING),
        "no empty facts heading may appear: {prompt:?}"
    );
}

/// Byte-stability: the cache property, asserted directly.
#[test]
fn session_facts_are_byte_stable_for_the_same_session_inputs() {
    let reg = multi_registry();
    let root = Some(PathBuf::from("/repo"));
    let first =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, false, &facts(&reg, &root));
    let second =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, false, &facts(&reg, &root));
    assert_eq!(
        first, second,
        "same session inputs must produce byte-identical prompts"
    );
}

/// The facts section states facts about the session, never a tool
/// inventory: the tool schemas are the authority on what is possible. (The
/// adjacent multi-connection paragraph names query tools as its navigation
/// instruction; that prose predates this section and is not the facts.)
#[test]
fn session_facts_name_no_tool() {
    for tool in [
        "workspace_write",
        "workspace_edit",
        "workspace_read",
        "run_command",
        "run_program",
        "schema_discovery",
    ] {
        let single = single_registry("main");
        let multi = multi_registry();
        let empty = ConnectionRegistry::new("main");
        let root = Some(PathBuf::from("/repo"));
        for (reg, root) in [(&single, &None), (&multi, &root), (&empty, &root)] {
            let text = session_facts_text(&facts(reg, root)).expect("session facts");
            assert!(
                !text.contains(tool),
                "the facts section must not name the {tool} tool: {text}"
            );
        }
    }
}

/// The session-aware prompt carries the facts. The workspace-less default
/// entry point keeps its prior bytes only insofar as it passes an empty
/// session; a single connection still yields a facts line naming the
/// connection and saying no workspace is bound.
#[test]
fn session_aware_prompt_adds_facts() {
    let reg = single_registry("main");
    let root = Some(PathBuf::from("/repo"));
    let with_session =
        assemble_system_prompt_with_session(&reg, MemoryMode::Off, false, &facts(&reg, &root))
            .expect("a prompt");
    assert!(
        with_session.contains(SESSION_FACTS_HEADING),
        "the session-aware prompt carries the facts: {with_session}"
    );
    assert!(
        with_session.contains("main") && with_session.contains("/repo"),
        "the facts name the connection and the root: {with_session}"
    );
}