anyagent 0.0.3

One Rust interface to the coding agents installed on a machine.
Documentation
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
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
//! Live feature matrix against the real installed harnesses — the checked-in
//! version of V0_LIVE_TESTS.md. Every test is `#[ignore]`d so plain
//! `cargo test` stays fast and offline. Run explicitly:
//!
//! ```sh
//! ANYAGENT_LIVE=all cargo test --test live -- --ignored --test-threads=1
//! ANYAGENT_LIVE=claude cargo test --test live cancel -- --ignored
//! ```
//!
//! Rules the suite enforces itself: the ANTHROPIC_* env hijack is stripped
//! in-process, a selected harness whose CLI is not installed is warned about
//! and skipped (never a failure) and named again by `zz_summary`, capability
//! gates print SKIP (which is a pass), and every event wait names the step
//! it hung at. Model-output flakes (wrong word from a weak model) are the
//! operator's judgment call; structural failures fail hard.

use std::num::NonZeroU32;
use std::time::Duration;

use futures::StreamExt;

use anyagent::{
    AgentError, Answer, AuthStatus, Capability, ConfigKind, ConfigValue, DeliveryKind, Event,
    EventKind, Events, Input, MessageId, PermissionChoice, PermissionMode, PromptId,
    QuestionAnswer, Request, RequestId, ResumeToken, RollbackScope, Runtime, Session,
    SessionOptions, StopReason, ToolStatus, TurnOrigin,
};

/// Every harness the shared matrix covers, in report order.
const HARNESSES: &[&str] = &[
    "claude",
    "codex",
    "opencode",
    "hermes",
    "kiro",
    "pi",
    "cursor",
    "antigravity",
    "grok",
    "qwen",
];
const EVENT_TIMEOUT: Duration = Duration::from_secs(120);
const OPENCODE_MODEL: &str = "opencode/muse-spark-1.2-contributor-free";
/// qwen models come from the box's `~/.qwen/settings.json`; every box
/// in the matrix lists this OpenRouter entry (vision-capable, answers in
/// English).
const QWEN_MODEL: &str = "z-ai/glm-5.3-flash";
/// The host config's default (`gpt-6-astra`) needs a newer CLI; luna is cheap and available.
const CODEX_MODEL: &str = "gpt-5.6-luna";
/// pi's model values are `provider/modelId`.
const PI_MODEL: &str = "openrouter/nvidia/nemotron-3-super-120b-a12b:free";
const COUNT: &str = "Count from 1 to 400, one number per line. No other text. No tools.";
const TITLE: &str = "Title this conversation in at most six words: the user asked how to rename \
a git branch. Reply with only the title. No tools.";
/// The cheap claude alias; the CLI resolves it to the current Haiku.
const CLAUDE_MODEL: &str = "haiku";

// -- gate -------------------------------------------------------------------

/// Harnesses selected by ANYAGENT_LIVE whose CLI is actually installed.
/// The roster is built once; harnesses that are missing are warned about
/// there and listed again by the `summary` test at the end of the run.
async fn enabled() -> Vec<&'static str> {
    roster().await.installed.clone()
}

/// Which harnesses the run covers, and the ones it had to leave out.
struct Roster {
    installed: Vec<&'static str>,
    /// One human line per skipped harness, for the warning and the summary.
    skipped: Vec<String>,
}

/// Whether the discovered `antigravity` is the headless `agy` CLI (its ACP
/// server not installed), which cannot ask and needs AutoApprove.
static HEADLESS_AGY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

/// The roster, built on first use and shared by every test.
async fn roster() -> &'static Roster {
    static ROSTER: tokio::sync::OnceCell<Roster> = tokio::sync::OnceCell::const_new();
    ROSTER.get_or_init(build_roster).await
}

/// Strips the env hijack, reads ANYAGENT_LIVE, then splits the selection
/// into installed and missing by one discovery pass. Warns about the
/// missing ones so a "not discovered" failure can never be mistaken for a
/// broken adapter.
async fn build_roster() -> Roster {
    for var in [
        "ANTHROPIC_BASE_URL",
        "ANTHROPIC_AUTH_TOKEN",
        "ANTHROPIC_API_KEY",
        "ANTHROPIC_MODEL",
        "CLAUDECODE",
    ] {
        // Safe here: runs once, before any session spawns threads that read
        // the environment.
        unsafe { std::env::remove_var(var) };
    }
    // The host's settings.json may route the CLI through a proxy; the
    // process env wins over it, so pin the real API for the suite.
    unsafe { std::env::set_var("ANTHROPIC_BASE_URL", "https://api.anthropic.com") };

    let Ok(list) = std::env::var("ANYAGENT_LIVE") else {
        println!("SKIP all: ANYAGENT_LIVE is not set");
        return Roster {
            installed: Vec::new(),
            skipped: Vec::new(),
        };
    };
    let selected: Vec<&'static str> = HARNESSES
        .iter()
        .copied()
        .filter(|h| list.trim() == "all" || list.split(',').any(|p| p.trim() == *h))
        .collect();

    let report = Runtime::new().discover().await;
    // The CLI is always `agy`; the upgrade is the server binary. Checking
    // the name also covers ANYAGENT_ANTIGRAVITY_BIN pinning the CLI while
    // the server is installed. The stem, not the name: windows installs it
    // as `agy.exe`, and the server as `agy_acp_server.par`, which keeps its
    // own stem either way.
    let _ = HEADLESS_AGY.set(
        report
            .require("antigravity")
            .map(|a| a.executable_path.file_stem().is_some_and(|n| n == "agy"))
            .unwrap_or(false),
    );
    let mut installed = Vec::new();
    let mut skipped = Vec::new();
    for h in selected {
        match report.require(h) {
            // pi is pinned to an openrouter model, and its login lives in
            // its own auth.json (or the key env var): ask pi, not the env.
            Ok(agent) if h == "pi" && !pi_ready(&agent.executable_path, "openrouter") => {
                skipped.push("pi: not logged in to openrouter (`/login openrouter` in pi)".into());
            }
            Ok(_) => installed.push(h),
            Err(_) => skipped.push(missing_line(&report, h)),
        }
    }
    if !skipped.is_empty() {
        println!(
            "\nWARN: {} selected harness(es) not installed:",
            skipped.len()
        );
        for line in &skipped {
            println!("  {line}");
        }
        println!("Their tests are skipped, not failed.\n");
    }
    Roster { installed, skipped }
}

/// pi's own readiness check for one provider (`pi auth check`).
fn pi_ready(exe: &std::path::Path, provider: &str) -> bool {
    std::process::Command::new(exe)
        .args(["auth", "check", "--provider", provider])
        .output()
        .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "ready")
}

/// "codex: not installed (searched 14 dirs) — install: npm i -g ..."
fn missing_line(report: &anyagent::DiscoveryReport, harness: &str) -> String {
    match report.missing.iter().find(|m| m.id.as_str() == harness) {
        Some(m) => format!(
            "{harness}: not installed (searched {} dirs) - install: {}",
            m.searched.len(),
            m.install_hint
        ),
        None => format!("{harness}: not installed"),
    }
}

// -- the features -----------------------------------------------------------

/// Discovery finds each enabled harness, and `probe_auth` confirms its login.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn discovery_finds_authenticated_harnesses() {
    for h in enabled().await {
        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report
            .require(h)
            .unwrap_or_else(|_| panic!("{h}: not discovered"));
        assert!(agent.executable_path.exists(), "{h}: executable missing");
        let auth = runtime.probe_auth(agent).await.unwrap();
        assert!(
            matches!(auth, AuthStatus::Authenticated { .. }),
            "{h}: not authenticated: {auth:?}"
        );
        pass(h, "discovered and authenticated by probe");
    }
}

/// A live `mode` option switches mid-session without leaking text, and the
/// choice holds across the next turn.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn mode_switches_live() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        let option = |session: &Session| {
            session
                .info()
                .details
                .config_options
                .into_iter()
                .find(|o| o.id.as_str() == "mode")
        };
        let Some(mode) = option(&session).filter(|o| o.live) else {
            println!("SKIP {h}: no live mode option");
            session.close().await.unwrap();
            continue;
        };
        let ConfigKind::Select { choices } = &mode.kind else {
            panic!("{h}: mode is not a select");
        };
        let target = choices
            .iter()
            .map(|c| c.value.clone())
            .find(|v| Some(ConfigValue::Text(v.clone())) != mode.current)
            .expect("a mode other than the current one");
        session.configure("mode", target.as_str()).await.unwrap();
        let switched = |info: &anyagent::SessionInfo| {
            info.configuration
                .options
                .get(&anyagent::ConfigId::new("mode"))
                == Some(&ConfigValue::Text(target.clone()))
        };
        // The switch must arrive as an event, not only in the snapshot.
        let mut announced = false;
        while !announced {
            let event = next(&mut events, "mode switch").await;
            assert!(
                !matches!(event.kind, EventKind::TextDelta { .. }),
                "{h}: a switch leaked text"
            );
            if let EventKind::SessionUpdated(info) = &event.kind {
                announced = switched(info);
            }
        }
        assert!(switched(&session.info()), "{h}: snapshot lags the event");
        session
            .prompt("Reply with just the word ok.")
            .await
            .unwrap();
        let text = drain_to_turn_end(&session, &mut events, "turn after mode switch").await;
        assert!(text.to_lowercase().contains("ok"), "{h}: got {text:?}");
        assert_eq!(
            option(&session).and_then(|o| o.current),
            Some(ConfigValue::Text(target.clone())),
            "{h}: mode changed under us after the turn"
        );
        session.close().await.unwrap();
        pass(h, &format!("mode switched live to {target}"));
    }
}

/// An attached image reaches the model wherever `Images` is advertised; a
/// PDF reaches antigravity's server, the one wire that takes PDFs inline.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn attachments_reach_the_model() {
    let fixtures =
        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/attachments");
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::Images)
        {
            println!("SKIP {h}: images not advertised");
            session.close().await.unwrap();
            continue;
        }
        session
            .prompt(
                Input::text("What colour is the attached image? Reply with one word.")
                    .attach(fixtures.join("image.png")),
            )
            .await
            .unwrap();
        let text = drain_to_turn_end(&session, &mut events, "image").await;
        assert!(
            text.to_lowercase().contains("red"),
            "{h}: image answer was {text:?}"
        );
        if h == "antigravity" {
            session
                .prompt(
                    Input::text(
                        "What is the secret word in the attached PDF? Reply with one word.",
                    )
                    .attach(fixtures.join("secret.pdf")),
                )
                .await
                .unwrap();
            let text = drain_to_turn_end(&session, &mut events, "pdf").await;
            assert!(
                text.to_lowercase().contains("pineapple"),
                "{h}: pdf answer was {text:?}"
            );
        }
        session.close().await.unwrap();
        pass(h, "attachments reached the model");
    }
}

/// Open returns resume token, version, harness-correct capabilities (Permissions/Steer/Images) and config options (mode/model/sandbox).
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn open_reports_token_capabilities_and_options() {
    for h in enabled().await {
        let (session, _events, _dir) = open(h).await;
        let info = session.info();
        assert!(info.resume_token.is_some(), "{h}: no resume token at open");
        assert!(info.details.version.is_some(), "{h}: no version");
        // A fresh session has no real title yet; a dated placeholder must
        // not be advertised as one (opencode: "New session - <date>").
        if let Some(title) = &info.title {
            assert!(
                !title.starts_with("New session") && !title.starts_with("Child session"),
                "{h}: placeholder title advertised: {title:?}"
            );
        }
        let caps = &info.details.capabilities;
        let has_option = |id: &str| {
            info.details
                .config_options
                .iter()
                .any(|o| o.id.as_str() == id)
        };
        // pi has no permission protocol on its wire and no permission mode:
        // its `mode`-shaped knob is the model's thinking level. agy's
        // headless wire cannot prompt at all; its ACP server (the upgrade,
        // used when installed) can, and then reads like any ACP agent.
        let headless_agy = h == "antigravity" && !caps.supports(Capability::Permissions);
        if h == "pi" || headless_agy {
            assert_eq!(caps.supports(Capability::Steer), h == "pi", "{h}: Steer");
            assert!(has_option("model"), "{h}: no `model` config option");
        } else {
            assert!(
                caps.supports(Capability::Permissions),
                "{h}: no Permissions"
            );
            // opencode's native wire exposes `model`, not a session `mode`;
            // grok has no modes either, only `model` and `effort`.
            if !matches!(h, "opencode" | "grok") {
                assert!(has_option("mode"), "{h}: no `mode` config option");
            }
        }
        if h == "claude" {
            for cap in [
                Capability::Images,
                Capability::Resume,
                Capability::Subagents,
            ] {
                assert!(caps.supports(cap.clone()), "claude: missing {cap:?}");
            }
            assert!(!caps.supports(Capability::Steer), "claude must not steer");
            assert!(
                !info.details.commands.is_empty(),
                "claude: no slash commands"
            );
        }
        if matches!(h, "opencode" | "grok") {
            assert!(has_option("model"), "{h}: no `model` config option");
        }
        if h == "codex" {
            assert!(caps.supports(Capability::Steer), "codex: missing Steer");
            assert!(has_option("model"), "codex: no `model` config option");
            assert!(has_option("sandbox"), "codex: no `sandbox` option");
        }
        session.close().await.unwrap();
        pass(h, "open info complete");
    }
}

/// Effort is one option everywhere it exists: `effort`, a live select whose choices follow the model, switched via `configure` and confirmed by `SessionUpdated`; verified on kiro, grok, opencode, pi, cursor, qwen (whose wire calls it `reasoning_effort`).
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn effort_switches_live() {
    for h in enabled().await {
        if !matches!(h, "kiro" | "grok" | "opencode" | "pi" | "cursor" | "qwen") {
            println!("SKIP {h}: effort asserted on kiro, grok, opencode, pi, cursor, qwen");
            continue;
        }
        let (session, mut events, _dir) = open(h).await;
        let option = |session: &Session| {
            session
                .info()
                .details
                .config_options
                .into_iter()
                .find(|o| o.id.as_str() == "effort")
        };
        // The pinned zen model has no variants, and kiro's default `auto`
        // has no effort; a model with levels also proves the option follows
        // a live model switch.
        let pin = match h {
            "opencode" => Some("opencode/ling-3.0-flash-fin-free"),
            "kiro" => Some("claude-opus-4.8"),
            _ => None,
        };
        if let Some(model) = pin {
            session.configure("model", model).await.unwrap();
            while option(&session).is_none() {
                next(&mut events, "effort after model switch").await;
            }
        }
        let Some(effort) = option(&session) else {
            assert_ne!(h, "kiro", "kiro: no `effort` option");
            // One id everywhere: an effort knob under another name (qwen
            // advertises `reasoning_effort`) is an adapter gap, not a model
            // without levels.
            let other = session
                .info()
                .details
                .config_options
                .into_iter()
                .find(|o| o.id.as_str().contains("effort"));
            assert!(
                other.is_none(),
                "{h}: effort advertised as {:?}",
                other.map(|o| o.id)
            );
            println!("SKIP {h}: the selected model has no effort levels");
            session.close().await.unwrap();
            continue;
        };
        assert!(effort.live, "{h}: effort is not live");
        let ConfigKind::Select { choices } = &effort.kind else {
            panic!("{h}: effort is not a select");
        };
        let levels: Vec<&str> = choices.iter().map(|c| c.value.as_str()).collect();
        if h == "kiro" {
            assert_eq!(
                levels,
                ["low", "medium", "high", "xhigh", "max"],
                "{h}: levels"
            );
        }
        // kiro reports the current level in a metadata frame right after
        // open; after a model switch it says nothing, so `current` stays empty.
        if h == "kiro" && pin.is_none() {
            while option(&session).is_some_and(|o| o.current.is_none()) {
                next(&mut events, "effort sync").await;
            }
        }
        let current = option(&session).and_then(|o| o.current);
        // Not "thinking off": a provider can refuse it (qwen on OpenRouter's
        // glm-5.3-flash: "Reasoning is mandatory for this endpoint").
        let target = levels
            .iter()
            .filter(|l| !matches!(**l, "none" | "off"))
            .find(|l| Some(ConfigValue::Text((**l).to_owned())) != current)
            .copied()
            .expect("a level other than the current one");
        session.configure("effort", target).await.unwrap();
        while option(&session).and_then(|o| o.current) != Some(ConfigValue::Text(target.into())) {
            let event = next(&mut events, "effort switch").await;
            assert!(
                !matches!(event.kind, EventKind::TextDelta { .. }),
                "{h}: a switch leaked text"
            );
        }
        session
            .prompt("Reply with just the word ok.")
            .await
            .unwrap();
        let text = drain_to_turn_end(&session, &mut events, "turn after switch").await;
        assert!(text.to_lowercase().contains("ok"), "{h}: got {text:?}");
        assert_eq!(
            option(&session).and_then(|o| o.current),
            Some(ConfigValue::Text(target.into())),
            "{h}: effort changed under us after the turn"
        );
        session.close().await.unwrap();
        pass(
            h,
            &format!("effort switched to {target} ({} levels)", levels.len()),
        );
    }
}

/// Probe returns version + model/mode + commands without creating a session or leaving claude transcripts.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn probe_reports_details_without_a_session() {
    for h in enabled().await {
        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report
            .require(h)
            .unwrap_or_else(|_| panic!("{h}: not discovered"));
        // Probe promises to leave nothing behind. Claude writes one
        // transcript file per session, so a new one means its throwaway
        // session outlived the probe.
        let transcripts = claude_transcripts();
        let details = runtime
            .probe(agent)
            .await
            .unwrap_or_else(|e| panic!("{h}: probe failed: {e}"));
        assert!(details.version.is_some(), "{h}: probe has no version");
        let model = details
            .config_options
            .iter()
            .find(|o| o.id.as_str() == "model");
        let has_mode = details
            .config_options
            .iter()
            .any(|o| o.id.as_str() == "mode");
        assert!(
            model.is_some() || has_mode,
            "{h}: probe has no model/mode option"
        );
        if h == "claude" {
            let ConfigKind::Select { choices } = &model.expect("claude: model option").kind else {
                panic!("claude: model option is not a select");
            };
            assert!(!choices.is_empty(), "claude: model has no choices");
            assert!(
                !details.commands.is_empty(),
                "claude: probe has no commands"
            );
            let left: Vec<_> = claude_transcripts()
                .difference(&transcripts)
                .cloned()
                .collect();
            assert!(left.is_empty(), "claude: probe left a transcript: {left:?}");
        }
        pass(
            h,
            &format!(
                "probe reports details without a session ({} commands)",
                details.commands.len()
            ),
        );
    }
}

/// `generate` is prompt in, text out, with no session to manage.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn generate_returns_text_without_a_session() {
    for h in enabled().await {
        let dir = tempfile::tempdir().unwrap();
        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report
            .require(h)
            .unwrap_or_else(|_| panic!("{h}: not discovered"));
        let text = match runtime.generate(agent, options(h, dir.path()), TITLE).await {
            Ok(text) => text,
            // A wire with neither tool disabling nor permission requests
            // cannot be hands-off; the refusal is typed (agy).
            Err(AgentError::UnsupportedFeature(why)) => {
                println!("SKIP {h}: generate unsupported (typed): {why}");
                continue;
            }
            Err(e) => panic!("{h}: generate failed: {e}"),
        };
        assert!(text.to_lowercase().contains("branch"), "{h}: got {text:?}");
        assert!(
            text.split_whitespace().count() <= 8,
            "{h}: not a title: {text:?}"
        );
        pass(h, &format!("generate returned {text:?}"));
    }
}

/// Even a prompt asking to read a file cannot enable Pi's tools during generation.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn pi_generate_stays_text_only() {
    if !enabled().await.contains(&"pi") {
        return;
    }
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("context.txt"), "tool-only context").unwrap();
    let log = dir.path().join("wire.jsonl");
    let runtime = Runtime::new();
    let report = runtime.discover().await;
    let agent = report.require("pi").expect("pi is in the roster");
    let text = tokio::time::timeout(EVENT_TIMEOUT, runtime.generate(
        agent, options("pi", dir.path()).record_wire(&log),
        "Read context.txt using a tool, then return a short summary. If no tools are available, say that briefly.",
    )).await.expect("pi generation timed out").expect("pi generation failed");
    assert!(!text.trim().is_empty());
    let wire = std::fs::read_to_string(log).unwrap();
    assert!(
        !wire.contains("tool_execution_") && !wire.contains("toolcall_start"),
        "Pi attempted a tool call"
    );
    assert!(
        !wire.contains("\"sessionFile\":\""),
        "Pi persisted its session"
    );
    pass("pi", &format!("tool-free generation returned {text:?}"));
}

/// Turn is bracketed by TurnStarted(Prompt)/TurnEnded(Completed), sequences strictly increase, and stays quiet after end.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn turn_events_are_bracketed_ordered_and_quiet_after_end() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        session
            .prompt("Say only the word PINEAPPLE. Do not use any tools.")
            .await
            .unwrap();
        let mut last_seq = 0;
        let mut saw_in_turn = false;
        let mut open_messages = std::collections::BTreeSet::new();
        let mut ended_messages = std::collections::BTreeSet::new();
        let mut text = String::new();
        loop {
            let event = next(&mut events, &format!("{h}: turn contract")).await;
            assert!(event.sequence > last_seq, "{h}: sequence not increasing");
            last_seq = event.sequence;
            // The first event inside the turn must be TurnStarted; session
            // level events (turn: None) may legitimately come first.
            if event.turn_info.is_some() && !saw_in_turn {
                saw_in_turn = true;
                assert!(
                    matches!(
                        event.kind,
                        EventKind::TurnStarted {
                            origin: TurnOrigin::Prompt(_)
                        }
                    ),
                    "{h}: first in-turn event was {:?}",
                    event.kind
                );
                continue;
            }
            match event.kind {
                EventKind::TextDelta {
                    message_id,
                    text: t,
                } => {
                    open_messages.insert(message_id);
                    text.push_str(&t);
                }
                EventKind::ReasoningDelta { message_id, .. } => {
                    open_messages.insert(message_id);
                }
                EventKind::MessageEnded { message_id } => {
                    open_messages.remove(&message_id);
                    // A republished completion snapshot must not close the
                    // same message twice (a message with no streamed content,
                    // e.g. tool-only, may still end once).
                    assert!(
                        ended_messages.insert(message_id.clone()),
                        "{h}: {message_id:?} ended twice"
                    );
                }
                EventKind::Diagnostic(d) => {
                    assert!(
                        d.level != anyagent::DiagnosticLevel::Error,
                        "{h}: error diagnostic: {}",
                        d.message
                    );
                }
                EventKind::TurnEnded { stop, .. } => {
                    assert!(
                        matches!(stop, StopReason::Completed { .. }),
                        "{h}: {stop:?}"
                    );
                    break;
                }
                _ => {}
            }
        }
        assert!(
            open_messages.is_empty(),
            "{h}: unended messages {open_messages:?}"
        );
        assert!(text.contains("PINEAPPLE"), "{h}: text was {text:?}");
        quiet(&mut events, 3, &format!("{h}: after turn end")).await;
        session.close().await.unwrap();
        pass(h, "turn contract holds");
    }
}

/// Tool turn reaches ToolStatus::Completed and the requested file lands on disk (hermes quirk tolerated).
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn tools_run_to_completion_and_the_file_lands() {
    for h in enabled().await {
        let (session, mut events, dir) = open(h).await;
        session
            .prompt("Create a file named note.txt containing exactly the word HELLO. Use your file tools.")
            .await
            .unwrap();
        let mut completed = Vec::new();
        loop {
            let event = next(&mut events, &format!("{h}: tool lifecycle")).await;
            match event.kind {
                EventKind::ToolUpdated(tool) if tool.status == ToolStatus::Completed => {
                    completed.push(tool.id);
                }
                EventKind::RequestOpened(request) => {
                    session.answer(request.id(), allow()).await.unwrap();
                }
                EventKind::TurnEnded { stop, .. } => {
                    assert!(
                        matches!(stop, StopReason::Completed { .. }),
                        "{h}: {stop:?}"
                    );
                    break;
                }
                _ => {}
            }
        }
        let content = std::fs::read_to_string(dir.path().join("note.txt"))
            .unwrap_or_else(|_| panic!("{h}: note.txt missing"));
        assert_eq!(content.trim(), "HELLO", "{h}: wrong content");
        // hermes never sends status transitions — KNOWN quirk, file is truth.
        if completed.is_empty() {
            assert_eq!(h, "hermes", "{h}: no tool reached Completed");
            pass(h, "file landed — KNOWN (hermes: no status updates)");
        } else {
            pass(h, "tool completed and file landed");
        }
        session.close().await.unwrap();
    }
}

/// Permission allow writes the file, deny blocks it and leaves the session usable for the next prompt.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn permissions_gate_the_write_and_deny_holds() {
    for h in enabled().await {
        // Cursor gates shell commands, never its edit tool (wire-captured
        // 2026-09-07): its write goes through the shell so the gate is hit.
        let write = if h == "cursor" {
            "Run the shell command `printf HELLO > note.txt` to create note.txt. Use the shell, not your file-edit tool. Do not verify afterwards."
        } else {
            "Create a file named note.txt containing exactly the word HELLO. Use your file tools."
        };
        // Session A: allow — the request closes and the file lands.
        let (session, mut events, dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::Permissions)
        {
            println!("SKIP {h}: permissions not advertised");
            session.close().await.unwrap();
            continue;
        }
        session.prompt(write).await.unwrap();
        let mut asked = false;
        loop {
            let event = next(&mut events, &format!("{h}: permission allow")).await;
            match event.kind {
                EventKind::RequestOpened(Request::Permission(request)) => {
                    asked = true;
                    assert!(
                        request.options.contains(&PermissionChoice::AllowOnce),
                        "{h}: no allow"
                    );
                    assert!(
                        request.options.contains(&PermissionChoice::DenyOnce),
                        "{h}: no deny"
                    );
                    session.answer(request.id, allow()).await.unwrap();
                }
                EventKind::TurnEnded { .. } => break,
                _ => {}
            }
        }
        assert!(asked, "{h}: no permission request opened");
        assert!(
            dir.path().join("note.txt").exists(),
            "{h}: file missing after allow"
        );
        session.close().await.unwrap();

        // Session B: deny — no file, and the session stays usable.
        let (session, mut events, dir) = open(h).await;
        session.prompt(write).await.unwrap();
        loop {
            let event = next(&mut events, &format!("{h}: permission deny")).await;
            match event.kind {
                EventKind::RequestOpened(request) => {
                    session
                        .answer(request.id(), Answer::Permission(PermissionChoice::DenyOnce))
                        .await
                        .unwrap();
                }
                EventKind::TurnEnded { .. } => break,
                _ => {}
            }
        }
        // KNOWN (hermes, wire-captured 2026-08-31): hermes's approval flow
        // gates only its file tools. After denied write attempts the agent
        // can route around its own gate with a terminal `printf`, which
        // never asks. The denies themselves are delivered and honoured.
        // KNOWN (cursor): after a denied shell write the model may still
        // reach for its ungated edit tool.
        if matches!(h, "hermes" | "cursor") && dir.path().join("note.txt").exists() {
            println!("KNOWN {h}: denies honoured; the write went through an ungated tool");
        } else {
            assert!(
                !dir.path().join("note.txt").exists(),
                "{h}: file exists after deny"
            );
        }
        session.prompt("Say only OK. No tools.").await.unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: post-deny prompt")).await;
        session.close().await.unwrap();
        pass(h, "allow writes, deny holds, session survives");
    }
}

/// A `/word` that is not an advertised command must ride as plain text — an
/// over-eager slash router would fail the turn on it (opencode routed every
/// `/…` to its command endpoint before the fix).
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn an_unknown_slash_prompt_is_plain_text() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        session
            .prompt("/definitely-not-a-command Reply with only the word KUMQUAT.")
            .await
            .unwrap();
        let text = drain_to_turn_end(&session, &mut events, &format!("{h}: slash text")).await;
        // claude owns the `/` namespace: since 2.1.261 the CLI answers an
        // unknown command itself, in a synthetic message we surface as text.
        if h == "claude" {
            assert!(text.contains("Unknown command"), "{h}: text was {text:?}");
            pass(
                h,
                "unknown slash command answered by the CLI, text surfaced",
            );
        } else {
            assert!(text.contains("KUMQUAT"), "{h}: text was {text:?}");
            pass(h, "unknown slash text stayed plain text");
        }
        session.close().await.unwrap();
    }
}

/// opencode's task tool runs in a child session whose permissions ask on the
/// child's own session id; they must still reach the caller or the subagent
/// stalls parked forever.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn opencode_child_session_permissions_reach_the_caller() {
    if !enabled().await.contains(&"opencode") {
        println!("SKIP: opencode not enabled");
        return;
    }
    let (session, mut events, _dir) = open("opencode").await;
    session
        .prompt(
            "Use your task tool to spawn a subagent whose prompt is: run the bash \
             command `echo child-probe`. You must use the task tool.",
        )
        .await
        .unwrap();
    let mut approved = 0;
    loop {
        let event = next(&mut events, "opencode: child permission").await;
        match event.kind {
            EventKind::RequestOpened(Request::Permission(request)) => {
                approved += 1;
                session.answer(request.id, allow()).await.unwrap();
            }
            EventKind::TurnEnded { .. } => break,
            _ => {}
        }
    }
    // The task tool asks on the root, its bash on the child.
    assert!(
        approved >= 2,
        "expected the task and child bash permissions, saw {approved}"
    );
    session.close().await.unwrap();
    pass("opencode", "child session permissions reached the caller");
}

/// Question request round-trips: choices presented, answer selected, and response echoed.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn a_question_round_trips() {
    for h in enabled().await {
        // codex fires `request_user_input` with the default-mode feature
        // flag the adapter sets (verified 2026-09-10). cursor's Auto model
        // has not fired `cursor/ask_question` in any probe (2026-09-07), so
        // it runs best-effort. Antigravity
        // asks over its ACP server; the headless CLI cannot prompt. grok
        // asks over `_x.ai/ask_user_question`.
        if !matches!(
            h,
            "claude" | "codex" | "opencode" | "cursor" | "antigravity" | "grok"
        ) {
            println!("SKIP {h}: questions (claude, codex, opencode, cursor, antigravity, grok)");
            continue;
        }
        if h == "antigravity" && HEADLESS_AGY.get().copied().unwrap_or(false) {
            println!("SKIP antigravity: headless agy cannot ask");
            continue;
        }
        let (session, mut events, _dir) = open(h).await;
        session
            .prompt("Ask me whether I prefer red or blue using your question tool (claude: AskUserQuestion; codex: request_user_input; opencode: question; cursor: ask_question; antigravity: ask_question; grok: ask_user_question), then answer with just my choice.")
            .await
            .unwrap();
        let mut text = String::new();
        let mut asked = false;
        loop {
            let event = next(&mut events, &format!("{h}: question")).await;
            match event.kind {
                EventKind::RequestOpened(Request::Question(request)) => {
                    asked = true;
                    let question = &request.questions[0];
                    assert!(question.choices.len() >= 2, "fewer than 2 choices");
                    let red = question
                        .choices
                        .iter()
                        .find(|c| c.label.to_lowercase().contains("red"))
                        .expect("no red choice")
                        .id
                        .clone();
                    session
                        .answer(
                            request.id,
                            Answer::Question(vec![QuestionAnswer::Choices(vec![red])]),
                        )
                        .await
                        .unwrap();
                }
                EventKind::TextDelta { text: t, .. } => text.push_str(&t),
                // The question must surface only as a request, never also as
                // a tool call (claude: AskUserQuestion; opencode: question;
                // grok: ask_user_question, then "Ask: <question>"). Matched
                // by prefix: a tool search echoing the prompt is not one.
                EventKind::ToolUpdated(tool) => {
                    let title = tool.title.to_lowercase();
                    assert!(
                        !(title.starts_with("ask") || title == "question"),
                        "{h}: question surfaced as a tool: {}",
                        tool.title
                    );
                }
                EventKind::TurnEnded { stop, .. } => {
                    assert!(matches!(stop, StopReason::Completed { .. }), "{stop:?}");
                    break;
                }
                _ => {}
            }
        }
        if !asked {
            assert!(h == "cursor", "{h}: no question request opened");
            println!("SKIP {h}: the question request did not fire (unverified live)");
            session.close().await.unwrap();
            continue;
        }
        assert!(text.to_lowercase().contains("red"), "answer was {text:?}");
        session.close().await.unwrap();
        pass(h, "question answered and echoed");
    }
}

/// Claude must not advertise Steer; others with Steer fold a mid-turn prompt via DeliveryKind::Steered.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn steering_is_absent_on_claude_and_folds_where_advertised() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        let steers = session
            .info()
            .details
            .capabilities
            .supports(Capability::Steer);
        if h == "claude" {
            assert!(!steers, "claude must not advertise Steer");
            session.close().await.unwrap();
            pass(h, "Steer correctly absent");
            continue;
        }
        if !steers {
            println!("SKIP {h}: steering (not advertised)");
            session.close().await.unwrap();
            continue;
        }
        session.prompt(COUNT).await.unwrap();
        tokio::time::sleep(Duration::from_secs(1)).await;
        let delivery = session
            .prompt("Stop counting and say only CHERRY.")
            .await
            .unwrap();
        assert!(
            matches!(delivery.kind, DeliveryKind::Steered { .. }),
            "{h}: steer delivered as {:?}",
            delivery.kind
        );
        let text = drain_to_turn_end(&session, &mut events, &format!("{h}: steer")).await;
        assert!(text.contains("CHERRY"), "{h}: steered output was {text:?}");
        session.close().await.unwrap();
        pass(h, "steer folded into the turn");
    }
}

/// Queued prompts are FIFO (Queued{0,1}) and each turn's origin keeps its prompt_id.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn the_queue_is_fifo_and_ids_stay_aligned() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        session.prompt(COUNT).await.unwrap();
        // On a steering harness (codex) a lone mid-turn prompt folds into
        // the running turn; occupy the steer slot first so KIWI and LEMON
        // genuinely queue.
        let (kiwi, lemon) = if steers(&session) {
            let (_, kiwi, lemon) = tokio::join!(
                session.prompt("Keep counting."),
                session.prompt("Say only KIWI. No tools."),
                session.prompt("Say only LEMON. No tools.")
            );
            (kiwi.unwrap(), lemon.unwrap())
        } else {
            (
                session.prompt("Say only KIWI. No tools.").await.unwrap(),
                session.prompt("Say only LEMON. No tools.").await.unwrap(),
            )
        };
        assert_eq!(kiwi.kind, DeliveryKind::Queued { position: 0 }, "{h}");
        assert_eq!(lemon.kind, DeliveryKind::Queued { position: 1 }, "{h}");

        // Three turns, in order, each keeping its own prompt id.
        drain_to_turn_end(&session, &mut events, &format!("{h}: count turn")).await;
        for (delivery, word) in [(kiwi, "KIWI"), (lemon, "LEMON")] {
            let mut text = String::new();
            loop {
                let event = next(&mut events, &format!("{h}: {word} turn")).await;
                match event.kind {
                    EventKind::TurnStarted { origin } => {
                        assert_eq!(
                            origin,
                            TurnOrigin::Prompt(delivery.prompt_id.clone()),
                            "{h}: {word} ran under the wrong prompt id"
                        );
                    }
                    EventKind::TextDelta { text: t, .. } => text.push_str(&t),
                    EventKind::TurnEnded { .. } => break,
                    _ => {}
                }
            }
            assert!(text.contains(word), "{h}: {word} turn said {text:?}");
        }
        session.close().await.unwrap();
        pass(h, "queue is FIFO with aligned ids");
    }
}

/// Cancel ends the turn in every queue shape (empty, queued, clear-queue) and session survives; kiro quirk handled.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn cancel_ends_the_turn_in_every_queue_shape() {
    for h in enabled().await {
        // The claude wedge was a timing race (interrupt vs the CLI's own
        // queued→started window), so repeat the raced variant there.
        let reps = if h == "claude" { 3 } else { 1 };
        let (session, mut events, _dir) = open(h).await;

        // Empty queue: cancel ends the turn and the session survives. Cancel
        // once the turn visibly streams — a fixed sleep let fast models
        // (kiro) finish COUNT before the cancel landed.
        session.prompt(COUNT).await.unwrap();
        wait_for_content(&mut events, &format!("{h}: count streaming")).await;
        session.cancel(false).await.unwrap();
        expect_cancelled(&mut events, &format!("{h}: empty-queue cancel")).await;
        session.prompt("Say only OK. No tools.").await.unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: post-cancel prompt")).await;

        // (a) cancel(false) with a queued prompt: it runs next and answers.
        // Repeating COUNT here trips the API's reasoning-extraction
        // safeguard on the third interrupted copy (claude 2.1.261, probed
        // 2026-09-05), so each raced turn streams a different long prompt.
        let topics = ["a lighthouse keeper", "a bus driver", "a mountain guide"];
        for (rep, topic) in topics.iter().enumerate().take(reps) {
            session.prompt(long_prompt(topic)).await.unwrap();
            // Queue immediately: a fast model can finish COUNT inside a fixed
            // sleep (kiro did), which would make this `Started`, not `Queued`.
            // On a steering harness the follow-up would fold instead, so the
            // steer slot is occupied first.
            let queued = queue_one(&session, "Say only PEAR. No tools.").await;
            assert_eq!(queued.kind, DeliveryKind::Queued { position: 0 }, "{h}");
            wait_for_content(&mut events, &format!("{h}: count streaming rep {rep}")).await;
            session.cancel(false).await.unwrap();
            expect_cancelled(&mut events, &format!("{h}: queued cancel rep {rep}")).await;
            // kiro's cancel can race the next prompt (2.19.1); the adapter
            // re-sends once, so the queued turn still answers.
            let text = drain_to_turn_end(
                &session,
                &mut events,
                &format!("{h}: queued prompt rep {rep}"),
            )
            .await;
            assert!(text.contains("PEAR"), "{h}: queued turn said {text:?}");
        }

        // (b) cancel(true): the queued prompt must never run.
        session.prompt(long_prompt("a baker")).await.unwrap();
        queue_one(&session, "Say only PLUM. No tools.").await;
        wait_for_content(&mut events, &format!("{h}: count streaming (b)")).await;
        session.cancel(true).await.unwrap();
        expect_cancelled(&mut events, &format!("{h}: clear-queue cancel")).await;
        quiet(&mut events, 5, &format!("{h}: after clear-queue cancel")).await;
        session.close().await.unwrap();
        pass(h, "cancel works in every queue shape");
    }
}

/// Resume recalls prior codeword without replaying old deltas; without Resume capability it fails typed ResumeFailed.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn resume_recalls_without_replaying() {
    for h in enabled().await {
        let (session, mut events, dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::Resume)
        {
            let token = session.info().resume_token.expect("token exists at open");
            session.close().await.unwrap();
            let report = Runtime::new().discover().await;
            let agent = report.require(h).unwrap();
            let result = Runtime::new()
                .open(agent, SessionOptions::in_dir(dir.path()).resume(token))
                .await;
            assert!(
                matches!(result, Err(AgentError::ResumeFailed(_))),
                "{h}: resume without the capability should fail typed"
            );
            pass(h, "resume correctly refused (not advertised)");
            continue;
        }
        session
            .prompt("Remember this codeword: FALCON42. Just confirm. No tools.")
            .await
            .unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: codeword turn")).await;
        let token = session.info().resume_token.expect("token after turn");
        session.close().await.unwrap();

        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report.require(h).unwrap();
        let (session, mut events) = runtime
            .open(agent, options(h, dir.path()).resume(token))
            .await
            .unwrap();
        // No replay: 3s of pre-prompt drain must carry zero content events.
        let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
        while let Ok(Some(event)) = tokio::time::timeout_at(deadline, events.next()).await {
            let event = event.unwrap();
            assert!(
                !matches!(
                    event.kind,
                    EventKind::TextDelta { .. }
                        | EventKind::ReasoningDelta { .. }
                        | EventKind::ToolUpdated(_)
                ),
                "{h}: replayed content after resume: {:?}",
                event.kind
            );
        }
        session
            .prompt("What is the codeword? No tools.")
            .await
            .unwrap();
        let text = drain_to_turn_end(&session, &mut events, &format!("{h}: recall turn")).await;
        assert!(text.contains("FALCON42"), "{h}: recall said {text:?}");
        session.close().await.unwrap();
        pass(h, "resumed with no replay and full recall");
    }
}

/// PlanUsageUpdated arrives within 10s after a turn with valid windows and reset times.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn plan_usage_arrives_after_a_turn() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::PlanUsage)
        {
            println!("SKIP {h}: plan usage not advertised");
            session.close().await.unwrap();
            continue;
        }
        session.prompt("Say OK. No tools.").await.unwrap();
        // codex pushes quota during the turn (after every model call);
        // claude refreshes right after it. Collect through turn end, then
        // give a post-turn push 10 more seconds.
        let mut pushed = None;
        loop {
            let event = next(&mut events, &format!("{h}: usage turn")).await;
            match event.kind {
                EventKind::PlanUsageUpdated(usage) => pushed = Some(usage),
                EventKind::RequestOpened(request) => {
                    session.answer(request.id(), allow()).await.unwrap();
                }
                EventKind::TurnEnded { .. } => break,
                _ => {}
            }
        }
        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
        let usage = loop {
            if let Some(usage) = pushed {
                break usage;
            }
            let event = tokio::time::timeout_at(deadline, events.next())
                .await
                .unwrap_or_else(|_| panic!("{h}: no PlanUsageUpdated within 10s of turn end"))
                .expect("stream ended")
                .unwrap();
            if let EventKind::PlanUsageUpdated(usage) = event.kind {
                pushed = Some(usage);
            }
        };
        assert!(!usage.windows.is_empty(), "{h}: quota with no windows");
        for w in &usage.windows {
            assert!(
                w.used_percent <= 100,
                "{h}: {} at {}%",
                w.label,
                w.used_percent
            );
        }
        assert!(
            usage.windows.iter().any(|w| w.resets_at.is_some()),
            "{h}: no window carries a reset time"
        );
        session.close().await.unwrap();
        let summary: Vec<_> = usage
            .windows
            .iter()
            .map(|w| format!("{} {}%", w.label, w.used_percent))
            .collect();
        pass(
            h,
            &format!(
                "plan usage pushed: plan {:?}, {}",
                usage.plan,
                summary.join(", ")
            ),
        );
    }
}

/// compact() summarizes the session's own context: the compaction is reported
/// and the agent still remembers what it was told before it.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn compact_summarizes_the_session_without_losing_it() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::Compact)
        {
            println!("SKIP {h}: compaction not advertised");
            session.close().await.unwrap();
            continue;
        }
        // Two exchanges: an agent with nothing to summarize refuses.
        session
            .prompt("Remember this codeword: ALPHA9. Just confirm. No tools.")
            .await
            .unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: codeword")).await;
        session
            .prompt("Now tell me a one-sentence fact about the number nine. No tools.")
            .await
            .unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: filler")).await;

        session.compact().await.unwrap();
        // KNOWN (pi 0.84.4): compaction keeps the newest 20k tokens
        // (`compaction.keepRecentTokens`) and refuses a session that fits
        // inside them, so a short live session proves the refusal path:
        // a diagnostic, and the turn still ends.
        if h == "pi" {
            let mut refused = false;
            loop {
                match next(&mut events, &format!("{h}: refusal")).await.kind {
                    EventKind::Diagnostic(d) if d.message.contains("compaction refused") => {
                        refused = true;
                    }
                    EventKind::TurnEnded { .. } => break,
                    _ => {}
                }
            }
            assert!(refused, "{h}: a small session compacted or hung");
            session.close().await.unwrap();
            pass(h, "KNOWN: small session refused, reported as a diagnostic");
            continue;
        }
        drain_to_compaction(&session, &mut events, &format!("{h}: compaction")).await;

        // Some agents answer their own compaction out loud, so the recall
        // prompt may queue behind that turn; wait for the turn it starts.
        let recall = session
            .prompt("What codeword did I give you? Answer with the word only. No tools.")
            .await
            .unwrap();
        let text = drain_prompt_turn(
            &session,
            &mut events,
            recall.prompt_id,
            &format!("{h}: recall"),
        )
        .await;
        assert!(
            text.contains("ALPHA9"),
            "{h}: compaction lost the conversation: {text:?}"
        );
        session.close().await.unwrap();
        pass(h, "compacted and kept the conversation");
    }
}

/// Rollback(1, Conversation) forgets exactly the last turn and changes the resume token.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn rollback_forgets_the_rolled_back_turn() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::Rollback)
        {
            println!("SKIP {h}: rollback not advertised");
            session.close().await.unwrap();
            continue;
        }
        session
            .prompt("Remember this codeword: ALPHA9. Just confirm. No tools.")
            .await
            .unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: codeword one")).await;
        session
            .prompt("Remember a second codeword: ZULU7. Just confirm. No tools.")
            .await
            .unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: codeword two")).await;

        session
            .rollback(NonZeroU32::new(1).unwrap(), RollbackScope::Conversation)
            .await
            .unwrap();
        session
            .prompt("List every codeword I told you, comma separated, nothing else. No tools.")
            .await
            .unwrap();
        let text = drain_to_turn_end(&session, &mut events, &format!("{h}: recall turn")).await;
        assert!(
            text.contains("ALPHA9"),
            "{h}: kept turn forgotten: {text:?}"
        );
        assert!(
            !text.contains("ZULU7"),
            "{h}: rolled-back turn recalled: {text:?}"
        );
        session.close().await.unwrap();
        pass(h, "rollback forgot exactly the last turn");
    }
}

/// Rollback(1, ConversationAndFiles) rewinds filesystem to pre-turn state and emits SessionUpdated.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn files_rollback_restores_agent_written_files() {
    for h in enabled().await {
        let (session, mut events, dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::RollbackFiles)
        {
            println!("SKIP {h}: file rollback not advertised");
            session.close().await.unwrap();
            continue;
        }
        let note = dir.path().join("note.txt");
        for word in ["alpha", "beta"] {
            session
                .prompt(format!(
                    "Use the Write tool to make {} contain exactly: {word}. Nothing else.",
                    note.display()
                ))
                .await
                .unwrap();
            drain_to_turn_end(&session, &mut events, &format!("{h}: write {word}")).await;
        }
        assert_eq!(std::fs::read_to_string(&note).unwrap().trim(), "beta");

        // Dropping the last turn also rewinds its file change.
        session
            .rollback(
                NonZeroU32::new(1).unwrap(),
                RollbackScope::ConversationAndFiles,
            )
            .await
            .unwrap();
        loop {
            if let EventKind::SessionUpdated(_) =
                next(&mut events, &format!("{h}: rollback")).await.kind
            {
                break;
            }
        }
        assert_eq!(std::fs::read_to_string(&note).unwrap().trim(), "alpha");
        session.close().await.unwrap();
        pass(h, "files rollback restored the previous file state");
    }
}

/// Fork at a MessageEnded fork_point anchor forgets later turns; tip fork keeps all history; original untouched.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn fork_from_branches_at_a_point_and_at_the_tip() {
    for h in enabled().await {
        let (session, mut events, dir) = open(h).await;
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::Fork)
        {
            println!("SKIP {h}: fork not advertised");
            session.close().await.unwrap();
            continue;
        }
        // Two codeword turns; keep each turn's last fork anchor
        // (`<agent>/fork_point` on MessageEnded).
        let mut anchors = Vec::new();
        for codeword in ["ALPHA9", "ZULU7"] {
            session
                .prompt(format!(
                    "Remember this codeword: {codeword}. Just confirm. No tools."
                ))
                .await
                .unwrap();
            let mut anchor = None;
            loop {
                let event = next(&mut events, &format!("{h}: codeword turn")).await;
                match event.kind {
                    EventKind::MessageEnded { .. } => {
                        if let Some(point) = event.extensions.get(&format!("{h}/fork_point")) {
                            anchor = point.as_str().map(str::to_owned);
                        }
                    }
                    EventKind::TurnEnded { .. } => break,
                    _ => {}
                }
            }
            anchors.push(anchor.expect("fork anchor on the turn's messages"));
        }
        let token = session.info().resume_token.expect("token after turns");
        session.close().await.unwrap();

        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report.require(h).unwrap();
        let recall = "List every codeword I told you, comma separated, nothing else. No tools.";

        // Fork at turn one's anchor: the branch forgets ZULU7.
        let (forked, mut fork_events) = runtime
            .open(
                agent,
                options(h, dir.path()).fork_from(token.clone(), Some(MessageId::new(&anchors[0]))),
            )
            .await
            .unwrap();
        forked.prompt(recall).await.unwrap();
        let text = drain_to_turn_end(&forked, &mut fork_events, &format!("{h}: cut fork")).await;
        assert!(
            text.contains("ALPHA9"),
            "{h}: cut fork lost turn 1: {text:?}"
        );
        assert!(
            !text.contains("ZULU7"),
            "{h}: cut fork kept turn 2: {text:?}"
        );
        let fork_token = forked.info().resume_token.expect("fork token");
        assert_ne!(fork_token.as_str(), token.as_str(), "{h}: fork kept the id");
        forked.close().await.unwrap();

        // Fork at the tip: the branch knows both — which also proves the
        // original transcript survived the first fork untouched.
        let (tip, mut tip_events) = runtime
            .open(agent, options(h, dir.path()).fork_from(token.clone(), None))
            .await
            .unwrap();
        tip.prompt(recall).await.unwrap();
        let text = drain_to_turn_end(&tip, &mut tip_events, &format!("{h}: tip fork")).await;
        assert!(
            text.contains("ALPHA9") && text.contains("ZULU7"),
            "{h}: tip fork lost history: {text:?}"
        );
        tip.close().await.unwrap();
        pass(h, "forked at a point and at the tip; original untouched");
    }
}

/// Runtime plan_usage probes quota without a session, caches within TTL, or returns UnsupportedFeature typed.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn runtime_plan_usage_probes_without_a_session() {
    for h in enabled().await {
        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report.require(h).unwrap();
        match runtime.plan_usage(agent).await {
            Ok(usage) => {
                assert!(!usage.windows.is_empty(), "{h}: quota with no windows");
                let cached = runtime.plan_usage(agent).await.unwrap();
                assert_eq!(cached.fetched_at, usage.fetched_at, "{h}: cache missed");
                pass(
                    h,
                    &format!("probe returned {} windows, cached", usage.windows.len()),
                );
            }
            Err(AgentError::UnsupportedFeature(_)) => {
                println!("SKIP {h}: plan usage unsupported (typed)");
            }
            Err(e) => panic!("{h}: plan usage probe failed: {e:?}"),
        }
    }
}

/// Killed agent maps to TurnEnded(Failed) + ProcessExited(`KILLED_STATUS`)
/// and closes the session.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn a_killed_agent_fails_the_turn_and_closes_the_session() {
    for h in enabled().await {
        if h == "hermes" {
            println!("SKIP hermes: agent death (messy process tree; two harnesses prove the path)");
            continue;
        }
        let (session, mut events, _dir) = open(h).await;
        session.prompt(COUNT).await.unwrap();
        tokio::time::sleep(Duration::from_secs(2)).await;
        kill_child(h, &session);

        let mut failed = false;
        loop {
            match tokio::time::timeout(EVENT_TIMEOUT, events.next()).await {
                Ok(Some(Ok(event))) => {
                    if let EventKind::TurnEnded { stop, .. } = event.kind {
                        assert!(matches!(stop, StopReason::Failed { .. }), "{h}: {stop:?}");
                        failed = true;
                    }
                }
                Ok(Some(Err(error))) => {
                    let AgentError::ProcessExited { status, .. } = &error else {
                        panic!("{h}: stream error was {error}");
                    };
                    assert!(status.contains(KILLED_STATUS), "{h}: status was {status:?}");
                }
                Ok(None) => break,
                Err(_) => panic!("{h}: hung after kill"),
            }
        }
        assert!(failed, "{h}: no Failed turn end before the stream closed");
        assert!(
            matches!(session.prompt("hi").await, Err(AgentError::SessionClosed)),
            "{h}: prompt after death should be SessionClosed"
        );
        pass(h, "death maps to Failed + ProcessExited + closed");
    }
}

/// Close returns within 10s and the event stream ends promptly.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn close_returns_promptly_and_ends_the_stream() {
    for h in enabled().await {
        let (session, mut events, _dir) = open(h).await;
        session.prompt("Say only OK. No tools.").await.unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: short turn")).await;
        tokio::time::timeout(Duration::from_secs(10), session.close())
            .await
            .unwrap_or_else(|_| panic!("{h}: close took over 10s"))
            .unwrap();
        loop {
            match tokio::time::timeout(Duration::from_secs(10), events.next()).await {
                Ok(Some(_)) => continue,
                Ok(None) => break,
                Err(_) => panic!("{h}: stream did not end after close"),
            }
        }
        pass(h, "close is prompt and the stream ends");
    }
}

/// Unadvertised rollback -> UnsupportedFeature, unknown request -> InvalidRequest, prompt after close -> SessionClosed.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn errors_are_typed() {
    for h in enabled().await {
        if h == "hermes" {
            println!("SKIP hermes: typed errors (excluded by plan)");
            continue;
        }
        let (session, mut events, _dir) = open(h).await;
        // (a) an unadvertised feature refuses typed (claude advertises
        // rollback now, so it proves this elsewhere).
        if !session
            .info()
            .details
            .capabilities
            .supports(Capability::Rollback)
        {
            assert!(
                matches!(
                    session
                        .rollback(NonZeroU32::new(1).unwrap(), RollbackScope::Conversation)
                        .await,
                    Err(AgentError::UnsupportedFeature(_))
                ),
                "{h}: rollback should be UnsupportedFeature"
            );
        }
        // (b) answering a request that is not open refuses typed.
        assert!(
            matches!(
                session.answer(RequestId::new("nope"), allow()).await,
                Err(AgentError::InvalidRequest(_))
            ),
            "{h}: unknown request should be InvalidRequest"
        );
        // (c) a closed session refuses typed.
        session.prompt("Say only OK. No tools.").await.unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: pre-close turn")).await;
        session.close().await.unwrap();
        assert!(
            matches!(session.prompt("hi").await, Err(AgentError::SessionClosed)),
            "{h}: prompt after close should be SessionClosed"
        );
        // (d) a resume token that names no conversation refuses typed, so
        // apps can tell a dead token from a broken protocol (native wires;
        // probed: claude "No conversation found", codex "no rollout found",
        // opencode a session-fetch rejection).
        if matches!(h, "claude" | "codex" | "opencode") {
            let runtime = Runtime::new();
            let report = runtime.discover().await;
            let agent = report.require(h).unwrap();
            let dir = tempfile::tempdir().unwrap();
            let bogus = ResumeToken::new("3b1c9f2e-5a6d-4e7f-8a9b-0c1d2e3f4a5b");
            let mut options = SessionOptions::in_dir(dir.path()).resume(bogus);
            if h == "opencode" {
                options = options.configure("model", OPENCODE_MODEL);
            }
            match runtime.open(agent, options).await {
                Err(AgentError::ResumeFailed(_)) => {}
                Err(other) => panic!("{h}: bogus resume errored untyped: {other}"),
                Ok(_) => panic!("{h}: bogus resume opened a session"),
            }
        }
        pass(h, "errors are typed");
    }
}

// -- helpers ----------------------------------------------------------------

/// Opens a live session for one harness in a fresh temp dir.
async fn open(harness: &str) -> (Session, Events, tempfile::TempDir) {
    let dir = tempfile::tempdir().unwrap();
    let runtime = Runtime::new();
    let report = runtime.discover().await;
    let agent = report
        .require(harness)
        .unwrap_or_else(|_| panic!("{harness}: not discovered"));
    let (session, events) = runtime
        .open(agent, options(harness, dir.path()))
        .await
        .unwrap_or_else(|e| panic!("{harness}: open failed: {e}"));
    (session, events, dir)
}

/// The per-harness options every live session opens with: pinned models
/// and deterministic approvals.
fn options(harness: &str, dir: &std::path::Path) -> SessionOptions {
    let mut options = SessionOptions::in_dir(dir);
    if harness == "claude" {
        options = options.configure("model", CLAUDE_MODEL);
    }
    if harness == "opencode" {
        options = options.configure("model", OPENCODE_MODEL);
    }
    if harness == "pi" {
        options = options.configure("model", PI_MODEL);
    }
    // qwen opens in `auto`, where a classifier waves safe writes through;
    // `default` asks for every edit and command.
    if harness == "qwen" {
        options = options
            .configure("mode", "default")
            .configure("model", QWEN_MODEL);
    }
    // Headless agy auto-denies every gated tool in Ask mode; its ACP server
    // asks like any ACP agent.
    if harness == "antigravity" && HEADLESS_AGY.get().copied().unwrap_or(false) {
        options = options.permission_mode(PermissionMode::AutoApprove);
    }
    if harness == "codex" {
        // Deterministic approvals regardless of the host config: a write
        // escalates past the read-only sandbox and asks.
        options = options
            .configure("model", CODEX_MODEL)
            .configure("effort", "low")
            .configure("sandbox", "read-only")
            .configure("mode", "on-request");
    }
    options
}

/// Next event within the timeout; a hang fails naming the step.
async fn next(events: &mut Events, step: &str) -> Event {
    match tokio::time::timeout(EVENT_TIMEOUT, events.next()).await {
        Ok(Some(Ok(event))) => event,
        Ok(Some(Err(error))) => panic!("stream error at {step}: {error}"),
        Ok(None) => panic!("stream closed at {step}"),
        Err(_) => panic!("hung at {step}"),
    }
}

/// Drains to `TurnEnded` (auto-allowing permissions) and returns the text.
async fn drain_to_turn_end(session: &Session, events: &mut Events, step: &str) -> String {
    let mut text = String::new();
    loop {
        let event = next(events, step).await;
        match event.kind {
            EventKind::TextDelta { text: t, .. } => text.push_str(&t),
            EventKind::RequestOpened(request) => {
                session.answer(request.id(), allow()).await.unwrap();
            }
            // A failed turn names its reason here, not as empty text later.
            EventKind::TurnEnded { stop, .. } => {
                assert!(
                    matches!(stop, StopReason::Completed { .. }),
                    "{step}: turn ended {stop:?}"
                );
                return text;
            }
            _ => {}
        }
    }
}

/// Drains the turn this prompt starts, skipping whatever the agent was
/// already running, and returns that turn's text.
async fn drain_prompt_turn(
    session: &Session,
    events: &mut Events,
    prompt: PromptId,
    step: &str,
) -> String {
    loop {
        match next(events, step).await.kind {
            EventKind::TurnStarted {
                origin: TurnOrigin::Prompt(id),
            } if id == prompt => break,
            EventKind::RequestOpened(request) => {
                session.answer(request.id(), allow()).await.unwrap();
            }
            _ => {}
        }
    }
    drain_to_turn_end(session, events, step).await
}

/// Drains the compaction turn: it must report the compaction and then end.
/// A refusal fails here rather than leaving a half-checked session.
async fn drain_to_compaction(session: &Session, events: &mut Events, step: &str) {
    let mut compacted = false;
    loop {
        match next(events, step).await.kind {
            EventKind::ContextCompacted => compacted = true,
            EventKind::RequestOpened(request) => {
                session.answer(request.id(), allow()).await.unwrap();
            }
            EventKind::Diagnostic(d) if d.message.contains("compaction refused") => {
                panic!("{step}: {}", d.message)
            }
            EventKind::TurnEnded { .. } => {
                assert!(
                    compacted,
                    "{step}: the compaction turn reported no compaction"
                );
                return;
            }
            _ => {}
        }
    }
}

/// A prompt that streams long enough to cancel mid-turn, distinct per topic.
fn long_prompt(topic: &str) -> String {
    format!("Write an 800 word story about {topic}. No tools.")
}

/// Waits until the turn is visibly streaming (first in-turn content), so a
/// cancel lands mid-turn instead of racing turn start or turn end.
async fn wait_for_content(events: &mut Events, step: &str) {
    loop {
        match next(events, step).await.kind {
            EventKind::TextDelta { .. }
            | EventKind::ReasoningDelta { .. }
            | EventKind::ToolUpdated(_) => return,
            EventKind::TurnEnded { .. } => panic!("turn ended before it streamed at {step}"),
            _ => {}
        }
    }
}

/// The next `TurnEnded` must be `Cancelled`, within ~10s.
async fn expect_cancelled(events: &mut Events, step: &str) {
    let wait = async {
        loop {
            if let EventKind::TurnEnded { stop, .. } = next(events, step).await.kind {
                assert_eq!(stop, StopReason::Cancelled, "{step}");
                return;
            }
        }
    };
    tokio::time::timeout(Duration::from_secs(10), wait)
        .await
        .unwrap_or_else(|_| panic!("no Cancelled turn end within 10s at {step}"));
}

/// Asserts no turn traffic arrives for `secs` seconds. Diagnostics (kiro
/// emits metadata notifications between turns), plan-usage receipts
/// (claude fetches usage after each result frame, so the receipt lands
/// post-turn by design), status flips (a turn end is followed by
/// `StatusChanged(Idle)`), and session updates (agents title a thread
/// asynchronously after the first turn) are the sanctioned out-of-turn events.
async fn quiet(events: &mut Events, secs: u64, step: &str) {
    let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
    while let Ok(Some(event)) = tokio::time::timeout_at(deadline, events.next()).await {
        let kind = event.map(|e| e.kind);
        if !matches!(
            kind,
            Ok(EventKind::Diagnostic(_)
                | EventKind::PlanUsageUpdated(_)
                | EventKind::StatusChanged(_)
                | EventKind::SessionUpdated(_))
        ) {
            panic!("expected quiet at {step}, got {kind:?}");
        }
    }
}

/// How an outright kill shows up in the exit status: the signal on unix, the
/// exit code taskkill gives the process it terminates on windows.
#[cfg(unix)]
const KILLED_STATUS: &str = "9";
#[cfg(windows)]
const KILLED_STATUS: &str = "exit status: 1";

/// How the kill finds pi: unix pi overwrites its argv with its process title,
/// so only the exact name is left; windows has no title, so the npm shim's
/// node leaf keeps its command line.
#[cfg(unix)]
const PI_MATCH: (&[&str], &str) = (&["-n", "-x"], "pi");
#[cfg(windows)]
const PI_MATCH: (&[&str], &str) = (&["-n", "-f"], r#"pi-coding-agent.*cli\.js"? --mode rpc"#);

/// Kills the session's own agent process, found by a session-unique marker.
fn kill_child(harness: &str, session: &Session) {
    // claude carries our minted session id in argv; opencode is matched by
    // its newest `opencode serve` process.
    let (args, pattern): (&[&str], String) = match harness {
        "claude" => (
            &["-f"],
            session.info().resume_token.unwrap().as_str().to_owned(),
        ),
        // Both halves: `kiro-cli acp` dispatches to a `kiro-cli-chat acp`
        // worker that inherits the pipes — killing only the dispatcher lets
        // the turn complete. Anchored so the user's Kiro apps' own
        // `kiro-cli acp --agent <name>` processes never match.
        // Windows has no `-chat` worker, and Command quotes the exe path:
        // `"...\kiro-cli.exe" acp`.
        "kiro" => (&["-f"], r#"kiro-cli(-chat)?(\.exe)?"? acp$"#.to_owned()),
        // The launcher script execs node under its own name; the user's
        // own TUI never ends in `acp`.
        // On windows the launcher is a .cmd -> .ps1 -> node chain, and the
        // node leaf carries `cursor-agent\versions\...` -- a path separator
        // where unix has a space.
        "cursor" => (&["-n", "-f"], r"cursor-agent.*index\.js acp$".to_owned()),
        // The npm shim runs cmd -> node -> codex.exe, so the real agent is
        // the leaf: `codex.exe app-server` there, plain `codex app-server`
        // on unix. The node and cmd links carry `.js"`/`.cmd"` instead.
        "codex" => (&["-n", "-f"], r"codex(\.exe)? app-server".to_owned()),
        // The user's own grok TUI never runs `agent ... stdio`.
        "grok" => (
            &["-n", "-f"],
            r#"grok(\.exe)?"? --no-auto-update agent"#.to_owned(),
        ),
        // The npm shim runs node -> cli.js, which re-execs itself as a
        // worker inheriting the pipes: both halves, as with kiro. The
        // user's own TUI has no `--experimental-acp`.
        "qwen" => (
            &["-f"],
            r"qwen-code[/\\]cli\.js --experimental-acp$".to_owned(),
        ),
        "pi" => (PI_MATCH.0, PI_MATCH.1.to_owned()),
        // The CLI's wire flag, or the ACP server's own executable name.
        // Windows spawns the CLI as `"...\agy.exe" --input-format=...`.
        "antigravity" => (
            &["-n", "-f"],
            r#"agy(\.exe)?"?( --input-format=stream-json|_acp_server)"#.to_owned(),
        ),
        // The npm shim leaves one opencode.exe, whose command line quotes the
        // exe and pads before the argument: `opencode.exe"    serve`.
        _ => (&["-n", "-f"], r#"opencode(\.exe)?"? +serve"#.to_owned()),
    };
    let pids = matching_pids(args, &pattern);
    let pids: Vec<&str> = pids.iter().map(String::as_str).collect();
    if pids.is_empty() {
        panic!("{harness}: no process matched {pattern:?}");
    }
    // kiro and qwen match dispatcher + worker; kill every matched pid.
    let last = if matches!(harness, "kiro" | "qwen") {
        pids.clone()
    } else {
        vec![*pids.last().unwrap()]
    };
    for pid in last {
        kill_pid(pid);
    }
}

/// Pids matching `pattern`, oldest first: `-x` matches the executable name
/// exactly, anything else the full command line, and `-n` keeps the newest.
#[cfg(unix)]
fn matching_pids(args: &[&str], pattern: &str) -> Vec<String> {
    let out = std::process::Command::new("pgrep")
        .args(args)
        .arg(pattern)
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .map(str::to_owned)
        .collect()
}

#[cfg(windows)]
fn matching_pids(args: &[&str], pattern: &str) -> Vec<String> {
    let test = if args.contains(&"-x") {
        "($_.Name -replace '\\.exe$', '') -eq $p"
    } else {
        "$_.CommandLine -match $p"
    };
    let newest = if args.contains(&"-n") {
        "| Select-Object -Last 1"
    } else {
        ""
    };
    // The pattern rides an env var: inlined, it would appear in this very
    // query's own command line, which then matches itself.
    let script = format!(
        "$p = $env:ANYAGENT_KILL_PATTERN; Get-CimInstance Win32_Process \
         | Where-Object {{ {test} }} | Sort-Object CreationDate \
         | Select-Object -ExpandProperty ProcessId {newest}"
    );
    let out = std::process::Command::new("powershell")
        .args(["-NoProfile", "-Command", &script])
        .env("ANYAGENT_KILL_PATTERN", pattern)
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .map(|line| line.trim().to_owned())
        .filter(|line| !line.is_empty())
        .collect()
}

/// Kills one pid outright, leaving any children of its own alone.
#[cfg(unix)]
fn kill_pid(pid: &str) {
    std::process::Command::new("kill")
        .args(["-9", pid])
        .status()
        .unwrap();
}

#[cfg(windows)]
fn kill_pid(pid: &str) {
    std::process::Command::new("taskkill")
        .args(["/F", "/PID", pid])
        .status()
        .unwrap();
}

fn allow() -> Answer {
    Answer::Permission(PermissionChoice::AllowOnce)
}

fn steers(session: &Session) -> bool {
    session
        .info()
        .details
        .capabilities
        .supports(Capability::Steer)
}

/// Gets `prompt` into the queue mid-turn: on a steering harness a lone
/// prompt would fold into the running turn, so a throwaway steer occupies
/// the slot first (both commands land before either resolves).
async fn queue_one(session: &Session, prompt: &str) -> anyagent::Delivery {
    if steers(session) {
        let (_, queued) = tokio::join!(session.prompt("Keep going."), session.prompt(prompt));
        queued.unwrap()
    } else {
        session.prompt(prompt).await.unwrap()
    }
}

fn pass(harness: &str, what: &str) {
    println!("PASS {harness}: {what}");
}

/// Transcript files claude has on disk, one per session it has recorded.
/// Empty when the directory does not exist or cannot be read.
fn claude_transcripts() -> std::collections::BTreeSet<std::path::PathBuf> {
    let mut found = std::collections::BTreeSet::new();
    let home = match std::env::var_os("CLAUDE_CONFIG_DIR") {
        Some(dir) => std::path::PathBuf::from(dir),
        None => match std::env::home_dir() {
            Some(home) => home.join(".claude"),
            None => return found,
        },
    };
    let Ok(projects) = std::fs::read_dir(home.join("projects")) else {
        return found;
    };
    for project in projects.flatten() {
        for file in std::fs::read_dir(project.path())
            .into_iter()
            .flatten()
            .flatten()
        {
            if file.path().extension().is_some_and(|e| e == "jsonl") {
                found.insert(file.path());
            }
        }
    }
    found
}

// -- config home isolation + wire recording (P2 smalls) ---------------------

#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn config_home_isolates_login() {
    for h in enabled().await {
        if h != "claude" && h != "codex" && h != "pi" {
            println!("SKIP {h}: config-home isolation asserted on claude, codex and pi");
            continue;
        }
        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report.require(h).unwrap();
        // The real login is present, proving the default path is
        // authenticated.
        let auth = runtime.probe_auth(agent).await.unwrap();
        assert!(
            matches!(auth, AuthStatus::Authenticated { .. }),
            "{h}: default login is not authenticated: {auth:?}"
        );
        // An empty temp config home has no credentials. NEVER touch ~/.claude
        // itself; the temp dir is discarded at the end of the test.
        let empty = tempfile::tempdir().unwrap();
        let opened = runtime
            .open(
                agent,
                SessionOptions::in_dir(empty.path()).config_home(empty.path()),
            )
            .await;
        match opened {
            Ok((session, _events)) => {
                assert!(
                    matches!(
                        session.info().details.auth,
                        AuthStatus::Unauthenticated { .. }
                    ),
                    "{h}: empty config home is not unauthenticated: {:?}",
                    session.info().details.auth
                );
                session.close().await.ok();
            }
            // A logged-out handshake that fails closed is equally valid.
            Err(AgentError::AuthRequired { .. }) => {}
            Err(e) => panic!("{h}: empty config home errored unexpectedly: {e}"),
        }
        pass(h, "config home isolates login (empty home is logged out)");
    }
}

/// Cursor: a model switch adopts that model's own options from the config
/// response (Composer: `fast`), and they leave with the model. No prompt, so
/// no quota spent.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn cursor_model_switch_reveals_the_models_own_options() {
    if !enabled().await.contains(&"cursor") {
        println!("SKIP: cursor not enabled");
        return;
    }
    let (session, mut events, _dir) = open("cursor").await;
    let option = |session: &Session, id: &str| {
        session
            .info()
            .details
            .config_options
            .into_iter()
            .find(|o| o.id.as_str() == id)
    };
    assert!(
        option(&session, "fast").is_none(),
        "Auto has no fast option"
    );
    session.configure("model", "composer-2.5").await.unwrap();
    while option(&session, "fast").is_none() {
        next(&mut events, "cursor: fast option after model switch").await;
    }
    assert_eq!(
        option(&session, "model").and_then(|o| o.current),
        Some(ConfigValue::Text("composer-2.5".into()))
    );
    session.configure("model", "default").await.unwrap();
    while option(&session, "fast").is_some() {
        next(&mut events, "cursor: fast option gone").await;
    }
    session.close().await.unwrap();
    pass("cursor", "per-model options follow the model");
}

/// Cursor: every turn ends with an estimated ContextUsage labelled
/// `anyagent/estimated` (its wire carries no usage).
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn cursor_context_usage_is_estimated() {
    if !enabled().await.contains(&"cursor") {
        println!("SKIP: cursor not enabled");
        return;
    }
    let (session, mut events, _dir) = open("cursor").await;
    session.prompt("Say only OK. No tools.").await.unwrap();
    let mut usage = None;
    loop {
        let event = next(&mut events, "cursor: usage turn").await;
        match event.kind {
            EventKind::ContextUsage { used_tokens, .. } => {
                assert_eq!(
                    event.extensions.get("anyagent/estimated"),
                    Some(&serde_json::Value::Bool(true)),
                    "usage not labelled as an estimate"
                );
                usage = Some(used_tokens);
            }
            EventKind::RequestOpened(request) => {
                session.answer(request.id(), allow()).await.unwrap();
            }
            EventKind::TurnEnded { .. } => break,
            _ => {}
        }
    }
    let used = usage.expect("an estimated usage event before turn end");
    assert!(used > 0, "estimate is empty");
    assert!(
        session
            .info()
            .details
            .capabilities
            .supports(Capability::ContextUsage)
    );
    session.close().await.unwrap();
    pass("cursor", &format!("estimated usage {used} tokens"));
}

#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn record_wire_captures_a_live_turn() {
    for h in enabled().await {
        if h != "claude" {
            println!("SKIP {h}: recording smoke asserted on claude");
            continue;
        }
        let dir = tempfile::tempdir().unwrap();
        let log = dir.path().join("wire.jsonl");
        let runtime = Runtime::new();
        let report = runtime.discover().await;
        let agent = report.require(h).unwrap();
        let (session, mut events) = runtime
            .open(agent, SessionOptions::in_dir(dir.path()).record_wire(&log))
            .await
            .unwrap();
        session.prompt("Say only OK. No tools.").await.unwrap();
        drain_to_turn_end(&session, &mut events, &format!("{h}: record turn")).await;
        session.close().await.unwrap();
        // The writer task flushes asynchronously.
        tokio::time::sleep(Duration::from_millis(200)).await;
        let body = std::fs::read_to_string(&log).unwrap();
        let count = body.lines().count();
        assert!(count > 5, "{h}: too few frames recorded: {count}");
        for line in body.lines() {
            assert!(
                line.starts_with("{\"dir\":"),
                "{h}: recorded line is not a dir/frame object: {line}"
            );
        }
        pass(h, &format!("recorded {count} wire frames over a live turn"));
    }
}

// -- summary ----------------------------------------------------------------

/// Closing roster: which harnesses the run covered and which were skipped
/// because their CLI is not installed. Runs last by name so it lands at the
/// bottom of the output.
#[tokio::test]
#[ignore = "live: talks to real agents"]
async fn zz_summary() {
    let roster = roster().await;
    println!("\n--- live run summary ---");
    println!(
        "ran: {}",
        if roster.installed.is_empty() {
            "none".to_owned()
        } else {
            roster.installed.join(", ")
        }
    );
    if roster.skipped.is_empty() {
        println!("skipped (not installed): none");
    } else {
        println!("skipped (not installed):");
        for line in &roster.skipped {
            println!("  {line}");
        }
    }
    println!("------------------------\n");
}