recursive-agent 0.8.3

A Rust coding-agent platform: kernel, tools, multi-agent orchestration, HTTP API, MCP, and TUI
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
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
//! Stateless execution kernel — `RunCore<'a>` and `RunInnerOutcome`.
//!
//! Extracted from `src/agent.rs` (Goal 213).  `RunCore` owns all the state
//! needed for one ReAct loop iteration and is consumed by `run_inner()`.
//! The containing `AgentKernel` constructs a `RunCore`, calls `run_inner()`,
//! and integrates the returned `RunInnerOutcome`.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};

/// Output of a single tool call execution, replacing the anonymous 5-tuple.
pub(crate) struct ToolCallOutcome {
    pub id: String,
    pub name: String,
    pub result: String,
    pub audit: Option<crate::tools::AuditMeta>,
}

/// Sentinel returned in the tool-result string when the permission denial
/// limit is exceeded.  Matches the check in `run_inner` that breaks the
/// ReAct loop.  Defined as a constant to avoid scattered string literals.
pub(crate) const DENIAL_LIMIT_SENTINEL: &str = "ERROR_DENIAL_LIMIT:";

use crate::compact::Compactor;
use crate::error::Result;
use crate::hooks::{HookAction, HookEvent, HookRegistry};
use crate::llm::{
    estimate_tokens, ChatProvider, Completion, ContextBreakdown, StreamChunk, StreamSender,
    TokenUsage, ToolCall, ToolSpec,
};
use crate::message::Message;

use crate::tools::plan_mode::{ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME};
use crate::tools::ToolRegistry;

use crate::agent::{FinishReason, PermissionDecision};
use crate::event::AgentEvent;
use crate::skills::Skill;
use crate::skills_injector::SkillInjector;
use crate::system_prompt::PromptSegments;
use crate::tools::PermissionHook;

/// Placeholder text used when trimming old tool results to fit the transcript budget.
pub(crate) const TRIM_PLACEHOLDER: &str = "[older tool output trimmed to fit budget]";

/// Minimum tool-result size (bytes) worth trimming; shorter results are kept verbatim.
const MIN_TRIM_LENGTH: usize = 200;

/// Goal-328: token estimate from a pre-computed byte count.
///
/// Same arithmetic as [`crate::llm::estimate_tokens`] but takes a `usize`
/// byte-count directly so the conversation bucket can avoid re-iterating
/// the transcript just to read each message's `len()`. Matches the public
/// helper's ceil semantics so a 5-byte transcript chunk is 2 tokens, not 1.
/// Byte-based (like the public helper): over-counts CJK ~3× because CJK
/// chars are 3 UTF-8 bytes each — intentional, and keeps the breakdown
/// buckets in the same unit as `estimate_tokens`.
fn estimate_tokens_by_bytes(bytes: usize) -> u32 {
    // NOTE: no `if tokens == 0 && bytes > 0 { 1 }` guard here — that branch
    // is dead: `tokens == 0` only when `bytes == 0` (ceil(0.25..0.75)=1), so
    // `bytes > 0` is never true when the guard could fire. Keeping it only
    // created behavior-equivalent `>`-operator mutants.
    (bytes as f64 / 4.0).ceil() as u32
}

/// Count one error occurrence for a tool name during stuck detection.
///
/// `#[mutants::skip]`: the `+= 1` mutants (`-= 1` / `*= 1`) are only
/// behavior-different when ≥2 tools repeat in the stuck window, but
/// `HashMap` iteration order is nondeterministic, so a test cannot pin
/// `max_by_key`'s tie-break without being flaky. The single-repeated-tool
/// case the counter feeds is covered by the stuck-detection integration
/// tests.
#[cfg_attr(test, mutants::skip)]
fn bump_stuck_count<'a>(counts: &mut std::collections::HashMap<&'a str, usize>, name: &'a str) {
    *counts.entry(name).or_default() += 1;
}

/// Render a [`FinishReason`] as the `reason` field of [`AgentEvent::TurnFinished`].
///
/// Delegates to the `Display` implementation on `FinishReason` to ensure
/// consistency between event payloads and HTTP API responses.
fn finish_reason_str(reason: &FinishReason) -> String {
    reason.to_string()
}

/// Rough token estimate from message content lengths.
///
/// Used as fallback when the LLM provider does not report `usage` in its
/// response. 4 chars per token is a conservative heuristic that works for
/// mixed English + CJK text; it will slightly *under*-estimate, which is
/// safer for the context gauge than over-estimating (false compaction
/// warnings). Divide by at least 1 so a zero-length message list doesn't
/// report zero tokens (the system prompt always exists).
fn estimate_prompt_tokens(messages: &[Message]) -> u32 {
    let total_chars: usize = messages.iter().map(|m| m.content.len()).sum();
    (total_chars / 4).max(1) as u32
}

/// Outcome returned by the stateless [`run_inner`] loop.
pub(crate) struct RunInnerOutcome {
    pub(crate) messages: Arc<Vec<Message>>,
    pub(crate) final_message: Option<String>,
    pub(crate) finish_reason: FinishReason,
    pub(crate) total_usage: TokenUsage,
    pub(crate) total_llm_latency_ms: u64,
    pub(crate) steps: usize,
    /// Goal-153: audit metadata for tool results, keyed by `(turn, tool_call_id)`.
    /// Only entries for successfully-dispatched tool calls are present;
    /// the key matches `Message.tool_call_id` on `Role::Tool` messages scoped
    /// by turn to prevent cross-turn id collisions.
    pub(crate) tool_audits:
        std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
    /// `prompt_tokens` from the **last** LLM call in this turn.
    ///
    /// Unlike `total_usage.prompt_tokens` (which is the accumulated sum across
    /// all LLM calls in the turn), this is the single most-recent reading from
    /// the API response. It represents the actual current context size and is
    /// the correct input to cross-turn compaction's `should_compact` check.
    pub(crate) last_prompt_tokens: u32,
}

/// Private core holding all state needed for one run of the ReAct loop.
///
/// Borrows immutable config from the parent [`Agent`]; owns the mutable
/// transcript and plan state.  `run_inner()` consumes `self` so the
/// loop cannot accidentally leave stale state behind.
pub(crate) struct RunCore<'a> {
    pub(crate) messages: Arc<Vec<Message>>,
    pub(crate) llm: Arc<dyn ChatProvider>,
    pub(crate) tools: Arc<ToolRegistry>,
    pub(crate) max_steps: usize,
    pub(crate) max_transcript_chars: Option<usize>,
    pub(crate) events: Option<mpsc::UnboundedSender<AgentEvent>>,
    pub(crate) streaming: bool,
    pub(crate) compactor: Option<Compactor>,
    /// Goal-332: micro-compactor for intra-turn tool-result pruning.
    /// When set, tool results are pruned at the end of each step BEFORE
    /// the next LLM call, keeping the transcript short enough that
    /// proactive cross-turn compaction rarely fires mid-response.
    /// NOTE: field exists but intra-turn invocation is not yet wired
    /// (Goal 332 added the type + field; Goal 333 wires cross-turn;
    /// a future goal will wire intra-turn in maybe_compact).
    #[allow(dead_code)]
    pub(crate) microcompactor: Option<crate::compact::Microcompactor>,
    pub(crate) permission_hook: Option<Arc<dyn PermissionHook>>,
    pub(crate) hooks: &'a HookRegistry,
    pub(crate) total_llm_latency_ms: u64,
    /// Goal-165: shared flag set by `EnterPlanModeTool`; blocks write tools
    /// while the agent is in read-only exploring / planning mode.
    pub(crate) exploring_plan_mode: Arc<AtomicBool>,
    /// Optional cancellation token. When cancelled, the step loop
    /// terminates at the next step boundary with
    /// [`FinishReason::Cancelled`].
    pub(crate) shutdown_token: Option<CancellationToken>,

    /// Optional mailbox for receiving mid-run messages from a coordinator.
    ///
    /// Drained at the start of every step so the agent sees coordinator
    /// instructions as user-role messages injected into the conversation.
    pub(crate) mailbox: Option<crate::tools::send_message::WorkerMailbox>,
    /// Sliding window size for stuck detection (from Config).
    pub(crate) stuck_window: usize,
    /// Error rate threshold to declare stuck (from Config).
    pub(crate) stuck_error_rate: f64,
    /// Turn index (0-based), scopes [`crate::tools::AuditKey`] prefixes so
    /// that cross-turn tool_call_id reuse cannot corrupt audit metadata.
    pub(crate) turn: u32,
    /// Goal-318: `Globs`-mode skills for path-triggered injection.
    /// Injected as system messages after tool calls match a skill's glob patterns.
    pub(crate) globs_skills: Vec<Skill>,
    /// Goal-328: structured prompt segments from `assemble_system_prompt`,
    /// used to size the static breakdown buckets (`system_prompt`, `rules`,
    /// `skills`, `subagents`). `None` when the runtime was built without a
    /// system-prompt path (tests, headless loops without prompts).
    ///
    /// Currently held for introspection / future hot-reload; the breakdown
    /// computation only reads [`Self::static_breakdown`] (which was sized
    /// at construction). The `#[allow(dead_code)]` silences the lint
    /// without removing the field — the goal explicitly preserves the
    /// structured segment accessor surface so callers can reason about
    /// which buckets contribute to the prompt.
    #[allow(dead_code)]
    pub(crate) prompt_segments: Option<PromptSegments>,
    /// Goal-328: per-bucket token counts for the static prompt portions,
    /// cached once at construction so the breakdown estimator does not
    /// re-tokenise `PromptSegments` every step. The `conversation`
    /// bucket is recomputed every step from `self.messages`.
    pub(crate) static_breakdown: StaticBreakdownCache,
    /// Goal-330: `prompt_tokens` from the most recent LLM response, used
    /// by [`Compactor::should_compact`] intra-turn. `0` means "no reading
    /// yet" (first step, or provider never reports usage).
    pub(crate) last_prompt_tokens: u32,
    /// Goal-331: consecutive proactive compaction failures. Resets to 0 on
    /// any successful compaction (including `Ok(None)` when transcript too
    /// short is **not** a failure — only actual `Err` from the provider or
    /// the compactor increments). When >= `MAX_CONSECUTIVE_COMPACT_FAILURES`,
    /// subsequent proactive compaction attempts are skipped.
    pub(crate) consecutive_compact_failures: u32,
    /// Goal 345: optional wall-clock deadline. When
    /// `wall_timeout_secs > 0`, the step loop checks elapsed time at
    /// each step boundary and terminates cleanly with
    /// [`FinishReason::WallClockExceeded`] when exceeded.
    pub(crate) wall_timeout_secs: u64,
    /// Wall-clock start instant, recorded when `RunCore` is
    /// constructed. `None` when the timeout is not active.
    pub(crate) wall_start: Option<std::time::Instant>,
}

/// Goal-328: cached token counts for the static breakdown buckets.
///
/// Sized once at `RunCore` construction from `PromptSegments`. The
/// `tools` and `mcp_dynamic` buckets are also cached because they only
/// change on a `/model` hot-swap or tool-registry change — the same
/// hook that re-creates the runtime. `conversation` and `overhead`
/// stay dynamic (recomputed every step).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct StaticBreakdownCache {
    pub system_prompt: u32,
    pub rules: u32,
    pub skills: u32,
    pub subagents: u32,
    /// Eager tool specs that are NOT MCP / NOT deferred.
    pub tools: u32,
    /// MCP / deferred tool specs.
    pub mcp_dynamic: u32,
}

impl StaticBreakdownCache {
    /// Build a fresh cache from a `PromptSegments` + the registry's tool
    /// specs. `registry` is consulted to partition `eager` vs
    /// `deferred_or_mcp` (McpTool reports `is_deferred() == true`).
    pub(crate) fn build(
        segments: &PromptSegments,
        specs: &[ToolSpec],
        registry: &ToolRegistry,
    ) -> Self {
        let mut tools = 0u32;
        let mut mcp_dynamic = 0u32;
        for spec in specs {
            // Mirror the serde-shape the provider adapter would send: a
            // single ToolSpec serialises to a JSON object with
            // name/description/parameters. We tokenise that JSON text
            // so the local estimate is comparable to what the provider
            // actually sees after wrapping.
            let text = serde_json::to_string(spec).unwrap_or_default();
            let n = estimate_tokens(&text);
            if registry.is_deferred_spec(spec) {
                mcp_dynamic = mcp_dynamic.saturating_add(n);
            } else {
                tools = tools.saturating_add(n);
            }
        }
        Self {
            system_prompt: estimate_tokens(&segments.system_prompt),
            rules: estimate_tokens(&segments.rules),
            skills: estimate_tokens(&segments.skills),
            subagents: estimate_tokens(&segments.subagents),
            tools,
            mcp_dynamic,
        }
    }
}

impl<'a> RunCore<'a> {
    fn emit(&self, event: AgentEvent) {
        if let Some(ref tx) = self.events {
            let _ = tx.send(event);
        }
    }

    fn push_message(&mut self, msg: Message) {
        Arc::make_mut(&mut self.messages).push(msg);
    }

    /// Attach `reasoning_content` to the last message in the transcript, if present.
    ///
    /// Called immediately after `push_message` on any path that produces reasoning
    /// tokens (both the no-tool-calls path and the tool-calls path share this logic).
    fn attach_reasoning_content(&mut self, reasoning: Option<String>) {
        if reasoning.is_some() {
            if let Some(msg) = Arc::make_mut(&mut self.messages).last_mut() {
                msg.reasoning_content = reasoning;
            }
        }
    }

    /// Goal-328: build a fresh [`ContextBreakdown`] from the cached static
    /// buckets + a re-tokenised `conversation` (the transcript body).
    /// `provider_total` is the `max(input_tokens, cache_hit + cache_miss)`
    /// reading from the just-completed LLM call; it backs the `overhead`
    /// bucket.
    fn compute_breakdown(&self, provider_total: u32) -> ContextBreakdown {
        // Conversation bucket: bytes/4 over the transcript body. We
        // intentionally re-tokenise every step (rather than caching) so
        // the bucket grows naturally with each new assistant / tool /
        // user message appended this run.
        let mut conversation_bytes: usize = 0;
        for msg in self.messages.iter() {
            conversation_bytes = conversation_bytes.saturating_add(msg.content.len());
            if let Some(rc) = &msg.reasoning_content {
                conversation_bytes = conversation_bytes.saturating_add(rc.len());
            }
        }
        // `estimate_tokens` uses (bytes as f64 / 4.0).ceil() as u32.
        let conversation = estimate_tokens_by_bytes(conversation_bytes);

        let local_sum = self
            .static_breakdown
            .system_prompt
            .saturating_add(self.static_breakdown.rules)
            .saturating_add(self.static_breakdown.skills)
            .saturating_add(self.static_breakdown.subagents)
            .saturating_add(self.static_breakdown.tools)
            .saturating_add(self.static_breakdown.mcp_dynamic)
            .saturating_add(conversation);
        let overhead = provider_total.saturating_sub(local_sum);

        ContextBreakdown {
            system_prompt: self.static_breakdown.system_prompt,
            rules: self.static_breakdown.rules,
            skills: self.static_breakdown.skills,
            subagents: self.static_breakdown.subagents,
            tools: self.static_breakdown.tools,
            mcp_dynamic: self.static_breakdown.mcp_dynamic,
            conversation,
            overhead,
        }
    }

    /// Goal-328: emit the [`AgentEvent::ContextBreakdown`] event for `step`.
    /// `usage` is the provider's reported truth for this step.
    fn emit_breakdown(&self, step: usize, usage: &TokenUsage) {
        // Match `UsageStats::record_with_cache`'s logic:
        //   max(input_tokens, cache_hit + cache_miss)
        // so Anthropic (cache_hit excludes input_tokens) and OpenAI
        // (cache_hit == 0, input_tokens is the full prompt) both feed
        // a sensible `provider_total`.
        let cache_sum = usage
            .cache_hit_tokens
            .saturating_add(usage.cache_miss_tokens);
        let provider_total = usage.prompt_tokens.max(cache_sum);
        let breakdown = self.compute_breakdown(provider_total);
        self.emit(AgentEvent::ContextBreakdown { breakdown, step });
    }

    /// Execute one LLM call for this step and surface everything the
    /// loop body needs: stream-forwarding task, latency tracking,
    /// token-usage accumulation, and the ordered `Reasoning` /
    /// `AssistantText` events.
    ///
    /// Returns the raw [`Completion`] plus `Some(text)` when the step
    /// produced non-empty assistant content (which becomes the
    /// candidate `final_message`). The caller decides what to do with
    /// the completion (no-tool-calls path vs. tool-dispatch path) and
    /// owns pushing assistant messages onto the transcript —
    /// `dispatch_llm_step` deliberately does not mutate `self.messages`.
    async fn dispatch_llm_step(
        &mut self,
        specs: &[crate::llm::ToolSpec],
        step: usize,
        total_usage: &mut TokenUsage,
    ) -> crate::error::Result<(Completion, Option<String>)> {
        debug!(target: "recursive::agent", step, "calling llm");
        let start = std::time::Instant::now();
        // Spin up the partial-token forwarder when streaming so the
        // UI gets live `PartialToken` / `PartialReasoning` events.
        let mut forward_handle = None;
        let stream_tx: Option<StreamSender> = if self.streaming {
            let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<StreamChunk>();
            let events_tx = self.events.clone();
            forward_handle = Some(tokio::spawn(async move {
                while let Some(chunk) = delta_rx.recv().await {
                    if let Some(ref tx) = events_tx {
                        let event = match chunk {
                            StreamChunk::Text(text) => AgentEvent::PartialToken { text, step },
                            StreamChunk::Reasoning(text) => {
                                AgentEvent::PartialReasoning { text, step }
                            }
                        };
                        let _ = tx.send(event);
                    }
                }
            }));
            Some(delta_tx)
        } else {
            None
        };
        let mut completion: Completion = self.call_llm(specs, stream_tx).await?;
        // Drain the partial-token forwarder before emitting any further
        // events. `call_llm` drops `stream_tx` on return, which closes
        // `delta_rx` and lets the spawned task finish; awaiting it
        // guarantees every `PartialToken` has been pushed to the event
        // sink *before* the finalising `AssistantText`. Without this the
        // two tasks race on the shared sink and a late token can arrive
        // after the assistant block was finalised, spawning a duplicate
        // streaming block in the UI.
        if let Some(handle) = forward_handle.take() {
            let _ = handle.await;
        }
        // Normalise chain-of-thought that the model emitted inline as
        // `<think>…</think>` in `content` (common for OpenAI-compatible
        // DeepSeek-R1 deployments that don't use the dedicated
        // `reasoning_content` SSE field) into the reasoning channel, so
        // the thinking block renders instead of being dropped by the
        // markdown HTML-block parser.
        completion.extract_inline_reasoning();
        let llm_ms = start.elapsed().as_millis() as u64;
        self.total_llm_latency_ms = self.total_llm_latency_ms.saturating_add(llm_ms);
        self.emit(AgentEvent::Latency { step, llm_ms });

        if let Some(mut u) = completion.usage {
            // 兼容面缺陷兜底:部分 Anthropic 兼容 provider(实测 GLM 流式 +
            // tools 请求)completion_tokens 有值但 input 侧全 0 且流里没有
            // message_delta 帧可补——输入用量真拿不到时用字节数估算填充,
            // 保证下游用量统计/成本核算的 input 侧不为 0。
            if u.prompt_tokens == 0 && u.completion_tokens > 0 {
                u.prompt_tokens = estimate_prompt_tokens(&self.messages);
                u.total_tokens = u.total_tokens.max(u.prompt_tokens + u.completion_tokens);
            }
            *total_usage = total_usage.accumulate(u);
            self.last_prompt_tokens = u.prompt_tokens;
            self.emit(AgentEvent::Usage {
                input_tokens: u.prompt_tokens,
                output_tokens: u.completion_tokens,
                cache_hit_tokens: u.cache_hit_tokens,
                cache_miss_tokens: u.cache_miss_tokens,
                step,
            });
            // Goal-328: emit the local per-component breakdown right after
            // the provider's `Usage` truth. The `overhead` bucket absorbs
            // the difference between our 7-bucket local sum and the
            // provider's reported `prompt_tokens` (computed as
            // `max(input_tokens, cache_hit + cache_miss)` so Anthropic
            // and OpenAI reporting differences are handled).
            self.emit_breakdown(step, &u);
        } else {
            // Provider did not report usage — emit a best-effort estimate
            // so the TUI context gauge still updates on every turn.
            let estimated_input = estimate_prompt_tokens(&self.messages);
            let output_len = completion.content.len()
                + completion.reasoning_content.as_ref().map_or(0, |s| s.len());
            let estimated_output = (output_len / 4).max(1) as u32;
            self.emit(AgentEvent::Usage {
                input_tokens: estimated_input,
                output_tokens: estimated_output,
                cache_hit_tokens: 0,
                cache_miss_tokens: 0,
                step,
            });
        }

        // Surface reasoning / thinking content to UI consumers (TUI) as
        // a separate event so it can be rendered as a `thinking…` block.
        // Providers that stream reasoning tokens accumulate them into
        // the final string before this point; we emit exactly once per
        // step. Emit this BEFORE AssistantText so the TUI's
        // `Reasoning { text }` block lands above the matching
        // `Assistant { text }` block — the model thinks first, then
        // speaks, and the visual order should match.
        if let Some(reasoning) = &completion.reasoning_content {
            if !reasoning.is_empty() {
                self.emit(AgentEvent::Reasoning {
                    text: reasoning.clone(),
                    step,
                });
            }
        }

        let new_final_message = if !completion.content.is_empty() {
            self.emit(AgentEvent::AssistantText {
                text: completion.content.clone(),
                step,
            });
            Some(completion.content.clone())
        } else {
            None
        };

        Ok((completion, new_final_message))
    }

    /// Process the result of a tool batch: emit per-call `ToolResult`
    /// events, push paired tool_result messages, run sliding-window
    /// stuck detection, then optionally terminate the run.
    ///
    /// Returns `Some(finish)` when the run should end after this batch
    /// (sentinel pre-pass hits `PermissionDenialLimit`, or stuck
    /// detection triggers `Stuck`); the caller routes the finish through
    /// [`make_outcome`]. Returns `None` when the loop should continue.
    ///
    /// **Invariant #8**: every tool_call in the batch is paired with a
    /// tool_result message before any early return. The sentinel pre-pass
    /// scans the whole batch first and flushes all tool_results
    /// atomically; the stuck-detection loop likewise pushes each
    /// tool_result before recording the (deferred) finish verdict. We
    /// never mid-batch return.
    ///
    /// When returning `None`, also runs the Globs-mode skill injector
    /// against the result strings so the next step can pick up
    /// path-triggered skills.
    #[allow(clippy::too_many_arguments)]
    fn process_tool_results(
        &mut self,
        results: &[ToolCallOutcome],
        step: usize,
        recent_errors: &mut std::collections::VecDeque<(bool, String)>,
        tool_audits: &mut std::collections::HashMap<
            crate::tools::AuditKey,
            crate::tools::AuditMeta,
        >,
        skill_injector: &mut SkillInjector,
    ) -> Option<FinishReason> {
        // Goal-285: sentinel pre-pass — detect before any push_message calls.
        // When the sentinel appears at index N > 0, flushing incrementally
        // would duplicate results[0..N] in the transcript; scan first, flush
        // once atomically, return immediately.
        if results.iter().any(|o| o.result == DENIAL_LIMIT_SENTINEL) {
            for o in results {
                if let Some(a) = &o.audit {
                    tool_audits.insert((self.turn, o.id.clone()), a.clone());
                }
                let is_error = o.result.starts_with("ERROR: ") || o.result == DENIAL_LIMIT_SENTINEL;
                self.emit(AgentEvent::ToolResult {
                    id: o.id.clone(),
                    name: o.name.clone(),
                    output: o.result.clone(),
                    step,
                    is_error,
                });
                self.push_message(Message::tool_result(o.id.clone(), o.result.clone()));
            }
            let finish = FinishReason::PermissionDenialLimit;
            self.emit(AgentEvent::TurnFinished {
                reason: finish_reason_str(&finish),
                steps: step,
            });
            return Some(finish);
        }

        // Stuck detection may fire while iterating the results of a
        // multi-call step. Returning mid-loop (as this code used to do)
        // left the assistant message's remaining tool_calls without
        // matching tool_result messages — orphaned `tool_use` blocks
        // that the provider rejects on the *next* turn with HTTP 400.
        // Record the stuck verdict here but defer the actual return
        // until the loop has pushed a tool_result for every call.
        let mut stuck_finish: Option<FinishReason> = None;
        for ToolCallOutcome {
            id,
            name,
            result,
            audit,
        } in results
        {
            let is_error = result.starts_with("ERROR: ");
            self.emit(AgentEvent::ToolResult {
                id: id.clone(),
                name: name.clone(),
                output: result.clone(),
                step,
                is_error,
            });
            if let Some(a) = audit {
                tool_audits.insert((self.turn, id.clone()), a.clone());
            }
            self.push_message(Message::tool_result(id.clone(), result.clone()));

            // Sliding-window stuck detection: track whether each tool call
            // was an error. Triggers when the error rate in the last
            // stuck_window steps exceeds stuck_error_rate, catching loops
            // that cycle across different tools (e.g. A→B→A→B).
            if recent_errors.len() == self.stuck_window {
                recent_errors.pop_front();
            }
            recent_errors.push_back((is_error, name.clone()));

            if stuck_finish.is_none() && recent_errors.len() == self.stuck_window {
                let error_entries: Vec<&str> = recent_errors
                    .iter()
                    .filter(|(is_err, _)| *is_err)
                    .map(|(_, n)| n.as_str())
                    .collect();
                let error_count = error_entries.len();
                let rate = error_count as f64 / self.stuck_window as f64;
                if rate >= self.stuck_error_rate {
                    let mut counts: std::collections::HashMap<&str, usize> =
                        std::collections::HashMap::new();
                    for n in &error_entries {
                        bump_stuck_count(&mut counts, n);
                    }
                    let top_tool = counts
                        .into_iter()
                        .max_by_key(|&(_, c)| c)
                        .map(|(n, _)| n)
                        .unwrap_or(name.as_str());
                    stuck_finish = Some(FinishReason::Stuck {
                        repeated_call: top_tool.to_string(),
                        repeats: error_count,
                    });
                }
            }
        }

        // Deferred stuck termination: every tool_call now has its matching
        // tool_result in the transcript, so ending the turn here cannot
        // leave orphaned `tool_use` blocks behind.
        if let Some(finish) = stuck_finish {
            self.emit(AgentEvent::TurnFinished {
                reason: finish_reason_str(&finish),
                steps: step,
            });
            return Some(finish);
        }

        // Goal-318: after every tool-result batch, check whether any result
        // references a path matching a Globs-mode skill. Inject once per skill.
        let result_strings: Vec<String> = results.iter().map(|r| r.result.clone()).collect();
        for (skill_name, skill_body) in skill_injector.check(&result_strings) {
            self.push_message(Message::system(format!(
                "<!-- skill:{skill_name} injected by globs match -->\n{skill_body}"
            )));
        }
        None
    }

    /// Finalise a step whose LLM completion carried no tool calls. Pushes
    /// the assistant message + reasoning onto the transcript, classifies
    /// the finish reason (`ProviderStop` for non-`stop`/`end_turn`
    /// provider reasons, else `NoMoreToolCalls`), emits `TurnFinished`,
    /// and returns `Some(finish)` so the caller routes through
    /// [`make_outcome`]. Returns `None` when the caller should continue
    /// with tool dispatch.
    fn handle_no_tool_calls(
        &mut self,
        completion: &Completion,
        step: usize,
    ) -> Option<FinishReason> {
        if !completion.tool_calls.is_empty() {
            return None;
        }
        self.push_message(Message::assistant(completion.content.clone()));
        self.attach_reasoning_content(completion.reasoning_content.clone());
        let finish = match completion.finish_reason.as_deref() {
            Some(r) if r != "stop" && r != "end_turn" => FinishReason::ProviderStop(r.to_string()),
            _ => FinishReason::NoMoreToolCalls,
        };
        self.emit(AgentEvent::TurnFinished {
            reason: finish_reason_str(&finish),
            steps: step,
        });
        Some(finish)
    }

    /// Drain any messages that a coordinator pushed via `send_message`
    /// since the last step, appending each as a user-role turn so the
    /// LLM sees coordinator instructions on the next reasoning step.
    /// No-op when no mailbox is configured.
    async fn drain_mailbox(&mut self) {
        let Some(mailbox) = self.mailbox.as_ref() else {
            return;
        };
        let pending = mailbox.drain_all().await;
        for msg_text in pending {
            self.push_message(Message {
                role: crate::message::Role::User,
                content: format!("[coordinator]: {msg_text}"),
                tool_calls: vec![],
                tool_call_id: None,
                reasoning_content: None,
                is_compaction_summary: false,
            });
        }
    }

    /// Apply transcript-budget enforcement for this step. When configured
    /// (`max_transcript_chars`) the transcript is trimmed and re-measured;
    /// if it still exceeds the limit, returns `Some((finish, step))` for
    /// the caller to route through [`make_outcome`].
    fn enforce_transcript_budget(
        &mut self,
        step: usize,
        total_usage: &TokenUsage,
    ) -> Option<(FinishReason, usize)> {
        let limit = self.max_transcript_chars?;
        self.maybe_trim_transcript(limit, step);
        let chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
        if chars < limit {
            return None;
        }
        let finish = FinishReason::TranscriptLimit { chars, limit };
        self.emit(AgentEvent::TurnFinished {
            reason: finish_reason_str(&finish),
            steps: step,
        });
        tracing::info!(
            target: "recursive::agent",
            steps = step,
            tokens_in = total_usage.prompt_tokens,
            tokens_out = total_usage.completion_tokens,
            finish = ?finish,
            llm_latency_ms = self.total_llm_latency_ms,
            "agent.run.complete"
        );
        Some((finish, step))
    }

    /// Check the shutdown token at the top of a step. Returns `Some((
    /// finish, finished_steps))` when the run should terminate with
    /// [`FinishReason::Cancelled`]; the caller routes the pair through
    /// [`make_outcome`]. `finished_steps` is `step - 1` because the
    /// current step's LLM call has not started yet.
    ///
    /// `total_usage` is read for the structured log line only — the
    /// outcome assembly happens at the call site.
    fn check_shutdown(
        &self,
        step: usize,
        total_usage: &TokenUsage,
    ) -> Option<(FinishReason, usize)> {
        let token = self.shutdown_token.as_ref()?;
        if !token.is_cancelled() {
            return None;
        }
        let finished_steps = step.saturating_sub(1);
        let finish = FinishReason::Cancelled;
        self.emit(AgentEvent::TurnFinished {
            reason: finish_reason_str(&finish),
            steps: finished_steps,
        });
        tracing::info!(
            target: "recursive::agent",
            steps = finished_steps,
            tokens_in = total_usage.prompt_tokens,
            tokens_out = total_usage.completion_tokens,
            finish = ?finish,
            llm_latency_ms = self.total_llm_latency_ms,
            "agent.run.complete"
        );
        Some((finish, finished_steps))
    }

    /// Goal 345: check the wall-clock deadline at the top of a step.
    /// Returns `Some((finish, finished_steps))` when the deadline has
    /// been exceeded; the caller routes through [`make_outcome`].
    fn check_wall_deadline(
        &self,
        step: usize,
        total_usage: &TokenUsage,
    ) -> Option<(FinishReason, usize)> {
        if self.wall_timeout_secs == 0 {
            return None;
        }
        let elapsed = self.wall_start?.elapsed();
        if elapsed.as_secs() < self.wall_timeout_secs {
            return None;
        }
        let finished_steps = step.saturating_sub(1);
        let finish = FinishReason::WallClockExceeded {
            secs: self.wall_timeout_secs,
        };
        self.emit(AgentEvent::TurnFinished {
            reason: finish_reason_str(&finish),
            steps: finished_steps,
        });
        tracing::info!(
            target: "recursive::agent",
            steps = finished_steps,
            tokens_in = total_usage.prompt_tokens,
            tokens_out = total_usage.completion_tokens,
            finish = ?finish,
            llm_latency_ms = self.total_llm_latency_ms,
            "agent.run.complete (wall clock)"
        );
        Some((finish, finished_steps))
    }

    /// Assemble the final outcome for a run that terminates with `finish`
    /// at step `steps`. All early-return paths in [`run_inner`] route here
    /// so the eight-field struct cannot drift out of sync across sites.
    fn make_outcome(
        self,
        finish: FinishReason,
        steps: usize,
        final_message: Option<String>,
        total_usage: TokenUsage,
        tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
    ) -> RunInnerOutcome {
        RunInnerOutcome {
            messages: self.messages,
            final_message,
            finish_reason: finish,
            total_usage,
            total_llm_latency_ms: self.total_llm_latency_ms,
            steps,
            tool_audits,
            last_prompt_tokens: self.last_prompt_tokens,
        }
    }

    /// Goal 353 — translate a mid-stream [`Error::Cancelled`] from the
    /// in-flight LLM call into a [`FinishReason::Cancelled`] outcome so the
    /// caller persists the partial transcript (Invariant #7).
    ///
    /// `finished_steps` is `step` — NOT `step - 1` like [`check_shutdown`]:
    /// the cancellation happened *during* the current step's LLM call, so the
    /// step is in-progress, not pre-call. The caller passes the loop's `step`
    /// verbatim; this helper deliberately does not compute it, keeping the
    /// step-vs-step-1 distinction visible at the call site. The event and log
    /// mirror [`check_shutdown`]'s so observers see Cancelled consistently.
    fn make_cancelled_outcome(
        self,
        step: usize,
        final_message: Option<String>,
        total_usage: TokenUsage,
        tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
    ) -> RunInnerOutcome {
        let finish = FinishReason::Cancelled;
        self.emit(AgentEvent::TurnFinished {
            reason: finish_reason_str(&finish),
            steps: step,
        });
        tracing::info!(
            target: "recursive::agent",
            steps = step,
            finish = ?finish,
            "agent.run.cancelled_mid_stream"
        );
        self.make_outcome(finish, step, final_message, total_usage, tool_audits)
    }

    /// Goal 382 — route a stream-interrupted completion to the Cancelled
    /// outcome. The SSE parsers (openai.rs / anthropic.rs) now return
    /// `Ok(Completion { finish_reason: Some("interrupted"), .. })` instead
    /// of `Err` when a network drop or user-initiated cancel cuts the
    /// stream after content arrived. This pushes the partial assistant
    /// message onto the transcript and finalises the turn as
    /// [`FinishReason::Cancelled`] via [`Self::make_cancelled_outcome`], so
    /// the caller's `emit_turn_messages` persists it (Invariant #7:
    /// transcript saved before the CLI maps the reason to an exit code).
    /// Kept as a sibling helper to protect Invariant #1 (loop body size).
    fn finish_interrupted(
        mut self,
        completion: Completion,
        step: usize,
        total_usage: TokenUsage,
        tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
    ) -> RunInnerOutcome {
        self.push_message(Message::assistant(completion.content.clone()));
        self.attach_reasoning_content(completion.reasoning_content.clone());
        self.make_cancelled_outcome(
            step,
            Some(completion.content), // partial reply becomes final_message
            total_usage,
            tool_audits,
        )
    }

    /// Call the LLM once, delegating retry handling to the provider's
    /// internal `RetryPolicy`. Goal-288 removed the outer retry loop so
    /// there is exactly one retry layer.
    ///
    /// When the registry has deferred tools, their names are injected as an
    /// `<available-deferred-tools>` user message prepended to the transcript,
    /// and only eager tool specs are passed to the provider. ToolSearchTool
    /// (registered by `freeze_deferred_specs`) appears in the eager list and
    /// its results in the message history are serialized as `tool_reference`
    /// blocks by `serialize_messages_anthropic`.
    /// Inject the skill catalog as a `<system-reminder>` user turn, appended
    /// to the **tail** of the per-request message copy.
    ///
    /// Why tail (not head): the catalog is volatile — it changes when skills
    /// load/unload. Placing it at the head (right after the leading `system`
    /// message, as an earlier draft did) busts the prefix cache for every
    /// message that follows it on each skill change. At the tail it sits in
    /// the most-recent turn, mirroring fake-cc's `skill_listing` attachment,
    /// which `reorderAttachmentsForAPI` bubbles to the end of the
    /// conversation. The static `system` field is untouched either way, so
    /// prefix caching of the system prompt + stable history is preserved.
    ///
    /// Correctness: appending a `user` turn at the tail is safe on both
    /// Anthropic and OpenAI-compatible providers. It never splits an
    /// assistant→tool_result pair (we only append, never insert), so
    /// AGENTS.md invariant #8 holds. The transcript (`self.messages`) is
    /// never mutated — only the per-request copy is wrapped.
    fn inject_skill_reminder(
        messages: &[crate::message::Message],
        skills: &[crate::skills::Skill],
    ) -> Vec<crate::message::Message> {
        if skills.is_empty() {
            return messages.to_vec();
        }
        let reminder = crate::skills::skill_reminder(skills);
        let mut out = Vec::with_capacity(messages.len() + 1);
        out.extend(messages.iter().cloned());
        out.push(crate::message::Message::user(reminder));
        out
    }

    async fn call_llm(
        &self,
        specs: &[crate::llm::ToolSpec],
        stream_sender: Option<crate::llm::StreamSender>,
    ) -> crate::error::Result<Completion> {
        // Deferred-tool partition: only when the provider supports tool_reference
        // (Anthropic). Other providers (OpenAI-compatible) receive all tools eagerly
        // — ToolSearchTool is not registered for them (see AgentRuntimeBuilder::build).
        let eager_specs_owned: Vec<crate::llm::ToolSpec>;
        let messages_with_deferred: Vec<crate::message::Message>;

        let (call_specs, messages): (&[crate::llm::ToolSpec], &[crate::message::Message]) =
            if self.llm.supports_deferred_tools() {
                let (eager, deferred): (Vec<_>, Vec<_>) = specs
                    .iter()
                    .cloned()
                    .partition(|s| !self.tools.is_deferred_spec(s));

                if deferred.is_empty() {
                    (specs, &self.messages)
                } else {
                    let names: Vec<&str> = deferred.iter().map(|s| s.name.as_str()).collect();
                    let block = format!(
                        "<available-deferred-tools>\n{}\n</available-deferred-tools>",
                        names.join("\n")
                    );
                    messages_with_deferred = std::iter::once(crate::message::Message::user(block))
                        .chain(self.messages.iter().cloned())
                        .collect();
                    eager_specs_owned = eager;
                    (&eager_specs_owned, &messages_with_deferred)
                }
            } else {
                (specs, &self.messages)
            };

        // Ship the skill catalog per-turn as a tail-appended `system-reminder`
        // so the static `system` prompt + stable history stay cacheable. Does
        // not mutate the transcript. See `inject_skill_reminder` for rationale.
        let injected: Vec<crate::message::Message> =
            Self::inject_skill_reminder(messages, &self.globs_skills);

        if let Some(ref tx) = stream_sender {
            let cancel_token = self.shutdown_token.clone();
            self.llm
                .stream(&injected, call_specs, Some(tx.clone()), cancel_token)
                .await
        } else {
            self.llm.complete(&injected, call_specs).await
        }
    }

    /// Trim old tool results to fit the transcript under `limit` chars.
    fn maybe_trim_transcript(&mut self, limit: usize, step: usize) {
        let mut chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
        if chars < limit {
            return;
        }

        let mut trimmed_count: usize = 0;
        let placeholder_len = TRIM_PLACEHOLDER.len();

        for msg in Arc::make_mut(&mut self.messages).iter_mut().skip(1) {
            if msg.role == crate::message::Role::Tool && msg.content.len() > MIN_TRIM_LENGTH {
                let old_len = msg.content.len();
                msg.content = TRIM_PLACEHOLDER.to_string();
                trimmed_count += 1;
                chars = chars
                    .saturating_sub(old_len)
                    .saturating_add(placeholder_len);
                if chars < limit {
                    break;
                }
            }
        }

        if trimmed_count > 0 {
            let note = format!(
                "[trimmed {} old tool result{} to fit budget]",
                trimmed_count,
                if trimmed_count == 1 { "" } else { "s" }
            );
            self.emit(AgentEvent::AssistantText { text: note, step });
        }
    }

    /// Compact the transcript if a [`Compactor`] is configured and the
    /// character budget is exceeded.
    ///
    /// `PreCompact` and `PostCompact` hooks are dispatched only when
    /// compaction actually runs (threshold is exceeded), not on every step.
    ///
    /// **Best-effort**: compaction failures are caught, logged, and counted
    /// by the circuit breaker. A failed compaction NEVER propagates to the
    /// caller — compaction is an optimization, not a correctness requirement.
    async fn maybe_compact(&mut self, step: usize) {
        let compactor = match &self.compactor {
            Some(c) => c,
            None => return,
        };

        // Circuit breaker: stop trying after too many consecutive failures.
        if self.consecutive_compact_failures >= crate::compact::MAX_CONSECUTIVE_COMPACT_FAILURES {
            self.emit(AgentEvent::CompactionSkipped {
                step,
                reason: crate::event::CompactionSkipReason::CircuitBreaker,
            });
            return;
        }

        let bytes = Compactor::estimate_bytes(&self.messages);
        if !compactor.should_compact(bytes, self.last_prompt_tokens) {
            return;
        }
        // Goal 345: only dispatch PreCompact when compaction will actually run.
        // `would_compact` mirrors `apply_to_transcript`'s degenerate-slice
        // rejection so PreCompact / PostCompact stay balanced (both fire, or
        // neither does) — without this the new Ok(None) path fired PreCompact
        // without a matching PostCompact.
        if !compactor.would_compact(&self.messages) {
            return;
        }

        // Only dispatch PreCompact when we're actually about to compact.
        self.hooks.dispatch(HookEvent::PreCompact {
            transcript_len: bytes,
        });

        let kept_before = self.messages.len();
        match compactor
            .apply_to_transcript(self.llm.as_ref(), Arc::make_mut(&mut self.messages), step)
            .await
        {
            Ok(Some((removed, summary_chars))) => {
                // Success — reset the circuit breaker.
                self.consecutive_compact_failures = 0;
                let kept = kept_before - removed;
                self.hooks.dispatch(HookEvent::PostCompact {
                    removed,
                    summary_chars,
                });
                self.emit(AgentEvent::Compacted {
                    removed,
                    kept,
                    summary_chars,
                    step,
                });
            }
            Ok(None) => {
                // Transcript too short to compact — not a failure, leave counter unchanged.
            }
            Err(e) => {
                // Compaction failed — increment the breaker, emit event, continue.
                tracing::warn!(error = %e, "proactive compaction failed");
                self.consecutive_compact_failures += 1;
                self.emit(AgentEvent::CompactionSkipped {
                    step,
                    reason: crate::event::CompactionSkipReason::Error,
                });
            }
        }
    }

    /// Execute a set of tool calls, returning `(id, name, output, args)` for each.
    /// Read-only calls are batched and executed in parallel; write calls run
    /// sequentially to preserve ordering guarantees.
    async fn execute_tool_calls(&self, calls: &[ToolCall]) -> Vec<ToolCallOutcome> {
        let mut results: Vec<ToolCallOutcome> = Vec::new();

        struct PendingCall {
            id: String,
            name: String,
            args: serde_json::Value,
        }
        let mut pending: Vec<PendingCall> = Vec::new();

        for call in calls {
            // Goal-165: while in agent-driven plan mode, block any write tool
            // that is not `exit_plan_mode` itself.
            if self.exploring_plan_mode.load(Ordering::Relaxed)
                && !self.tools.is_readonly(&call.name)
                && call.name != EXIT_PLAN_MODE_TOOL_NAME
            {
                results.push(ToolCallOutcome {
                    id: call.id.clone(),
                    name: call.name.clone(),
                    result: format!(
                        "ERROR: Cannot execute '{}' in plan mode. \
                         You are in read-only planning mode. \
                         Explore freely with read tools, then call \
                         exit_plan_mode with your plan.",
                        call.name
                    ),
                    audit: None,
                });
                continue;
            }

            // Goal-190: if a tool is in Plan mode and we're not in plan mode,
            // tell the agent to enter plan mode first.
            if !self.exploring_plan_mode.load(Ordering::Relaxed)
                && self.tools.is_plan_mode(&call.name)
                && call.name != ENTER_PLAN_MODE_TOOL_NAME
                && call.name != EXIT_PLAN_MODE_TOOL_NAME
            {
                results.push(ToolCallOutcome {
                    id: call.id.clone(),
                    name: call.name.clone(),
                    result: format!(
                        "ERROR: Tool '{}' requires plan mode. \
                         Call enter_plan_mode first to explore and plan, \
                         then call exit_plan_mode with your plan before using this tool.",
                        call.name
                    ),
                    audit: None,
                });
                continue;
            }

            let effective_args = if let Some(ref hook) = self.permission_hook {
                match hook.check(&call.name, &call.arguments).await {
                    PermissionDecision::Allow => call.arguments.clone(),
                    PermissionDecision::Deny(reason) => {
                        let result = format!("ERROR: {reason}");
                        results.push(ToolCallOutcome {
                            id: call.id.clone(),
                            name: call.name.clone(),
                            result,
                            audit: None,
                        });
                        continue;
                    }
                    PermissionDecision::Transform(new_args) => new_args,
                }
            } else {
                call.arguments.clone()
            };

            let hook_action = self.hooks.dispatch(HookEvent::PreToolCall {
                name: &call.name,
                args: &effective_args,
            });
            match hook_action {
                HookAction::Skip => {
                    let result = "ERROR: tool call skipped by hook".to_string();
                    results.push(ToolCallOutcome {
                        id: call.id.clone(),
                        name: call.name.clone(),
                        result,
                        audit: None,
                    });
                    continue;
                }
                HookAction::Error(msg) => {
                    let result = format!("ERROR: {msg}");
                    results.push(ToolCallOutcome {
                        id: call.id.clone(),
                        name: call.name.clone(),
                        result,
                        audit: None,
                    });
                    continue;
                }
                HookAction::Continue => {}
            }

            pending.push(PendingCall {
                id: call.id.clone(),
                name: call.name.clone(),
                args: effective_args,
            });
        }

        let mut i = 0;
        while i < pending.len() {
            if self
                .tools
                .is_readonly_for_call(&pending[i].name, &pending[i].args)
            {
                let batch_start = i;
                while i < pending.len()
                    && self
                        .tools
                        .is_readonly_for_call(&pending[i].name, &pending[i].args)
                {
                    i += 1;
                }
                let batch: Vec<PendingCall> = pending.drain(batch_start..i).collect();
                i = batch_start;

                let mut join_set = tokio::task::JoinSet::new();
                for pc in &batch {
                    let name = pc.name.clone();
                    let id = pc.id.clone();
                    let args = pc.args.clone();
                    let tools = Arc::clone(&self.tools);
                    join_set.spawn(async move {
                        let tool_start = std::time::Instant::now();
                        let dispatch = tools.invoke_with_audit(&name, args).await;
                        let result = match dispatch.result {
                            Ok(output) => output,
                            Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
                                DENIAL_LIMIT_SENTINEL.to_string()
                            }
                            Err(err) => format!("ERROR: {err}"),
                        };
                        let duration_ms = tool_start.elapsed().as_millis() as u64;
                        (id, name, result, dispatch.audit, duration_ms)
                    });
                }

                type BatchRow = (String, String, String, crate::tools::AuditMeta, u64);
                // Use a HashMap keyed by tool_call_id so that O(1) lookup
                // preserves the correct audit metadata when multiple parallel
                // calls complete in arbitrary order (linear find would always
                // hit the first matching id, causing silent audit mis-attribution).
                let mut batch_map: std::collections::HashMap<String, BatchRow> =
                    std::collections::HashMap::with_capacity(batch.len());
                while let Some(res) = join_set.join_next().await {
                    match res {
                        Ok(row) => {
                            batch_map.insert(row.0.clone(), row);
                        }
                        Err(e) => {
                            tracing::error!(target: "recursive::agent", "parallel tool task panicked: {e}");
                        }
                    }
                }

                for pc in &batch {
                    let Some((_, _, result, audit, duration_ms)) = batch_map.get(&pc.id) else {
                        // Task panicked — push a placeholder error result so
                        // the tool-call ↔ tool-result pairing invariant (#8)
                        // is preserved. Without this, the next LLM request
                        // would include an orphaned tool_call with no matching
                        // tool result and be rejected with HTTP 400.
                        results.push(ToolCallOutcome {
                            id: pc.id.clone(),
                            name: pc.name.clone(),
                            result: "ERROR: tool task panicked during parallel execution"
                                .to_string(),
                            audit: None,
                        });
                        continue;
                    };
                    results.push(ToolCallOutcome {
                        id: pc.id.clone(),
                        name: pc.name.clone(),
                        result: result.clone(),
                        audit: Some(audit.clone()),
                    });
                    self.hooks.dispatch(HookEvent::PostToolCall {
                        name: &pc.name,
                        args: &pc.args,
                        result,
                        duration_ms: *duration_ms,
                    });
                }
            } else {
                let pc = pending.remove(i);
                let tool_start = std::time::Instant::now();
                let dispatch = self
                    .tools
                    .invoke_with_audit(&pc.name, pc.args.clone())
                    .await;
                let result = match dispatch.result {
                    Ok(output) => output,
                    Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
                        DENIAL_LIMIT_SENTINEL.to_string()
                    }
                    Err(err) => format!("ERROR: {err}"),
                };
                let duration_ms = tool_start.elapsed().as_millis() as u64;
                results.push(ToolCallOutcome {
                    id: pc.id.clone(),
                    name: pc.name.clone(),
                    result: result.clone(),
                    audit: Some(dispatch.audit),
                });
                self.hooks.dispatch(HookEvent::PostToolCall {
                    name: &pc.name,
                    args: &pc.args,
                    result: &result,
                    duration_ms,
                });
            }
        }

        results
    }

    /// Stateless core ReAct loop.  Consumes `self` and returns a
    /// [`RunInnerOutcome`] that the wrapper integrates into the parent agent.
    pub(crate) async fn run_inner(mut self) -> Result<RunInnerOutcome> {
        let specs = self.tools.specs();

        let mut final_message: Option<String> = None;
        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
            std::collections::VecDeque::with_capacity(self.stuck_window);
        let mut total_usage = TokenUsage::default();
        // Goal-318: one injector per run; tracks already-fired globs skills.
        let mut skill_injector = SkillInjector::new(&self.globs_skills);
        // Goal-153: audit metadata for tool calls, keyed by (turn, tool_call_id).
        let mut tool_audits: std::collections::HashMap<
            crate::tools::AuditKey,
            crate::tools::AuditMeta,
        > = std::collections::HashMap::new();
        self.total_llm_latency_ms = 0;

        let step_cap = effective_step_limit(self.max_steps);
        for step in 1..=step_cap {
            let step_span = tracing::info_span!("agent.step", step);
            let _guard = step_span.enter();

            // ---- shutdown cancellation -------------------------------------------
            // Termination check, parallel to the BudgetExceeded /
            // TranscriptLimit blocks below. If a CancellationToken was
            // configured (via AgentRuntimeBuilder::shutdown_token / etc.)
            // and it fired between steps, finish cleanly with
            // FinishReason::Cancelled.
            if let Some((finish, finished_steps)) = self.check_shutdown(step, &total_usage) {
                return Ok(self.make_outcome(
                    finish,
                    finished_steps,
                    final_message,
                    total_usage,
                    tool_audits,
                ));
            }

            // ---- wall-clock deadline (Goal 345) ---------------------------------
            if let Some((finish, finished_steps)) = self.check_wall_deadline(step, &total_usage) {
                return Ok(self.make_outcome(
                    finish,
                    finished_steps,
                    final_message,
                    total_usage,
                    tool_audits,
                ));
            }

            // ---- mailbox drain (coordinator → worker mid-run messages) -----------
            self.drain_mailbox().await;

            // ---- transcript budget ------------------------------------------------
            if let Some((finish, finish_step)) = self.enforce_transcript_budget(step, &total_usage)
            {
                return Ok(self.make_outcome(
                    finish,
                    finish_step,
                    final_message,
                    total_usage,
                    tool_audits,
                ));
            }

            // ---- compaction -------------------------------------------------------
            self.maybe_compact(step).await;

            // ---- LLM call (with retry) --------------------------------------------
            let (completion, new_final_message) =
                match self.dispatch_llm_step(&specs, step, &mut total_usage).await {
                    Ok(v) => v,
                    Err(crate::error::Error::Cancelled) => {
                        // Mid-stream cancellation: the stream was interrupted partway
                        // through an LLM call. Route to FinishReason::Cancelled so the
                        // partial transcript is persisted by the caller. Invariant #7.
                        return Ok(self.make_cancelled_outcome(
                            step,
                            final_message,
                            total_usage,
                            tool_audits,
                        ));
                    }
                    Err(e) => return Err(e),
                };
            // Goal 382: a stream-interrupted completion (finish_reason
            // "interrupted") persists the partial reply as a Cancelled turn.
            if completion.finish_reason.as_deref() == Some("interrupted") {
                return Ok(self.finish_interrupted(completion, step, total_usage, tool_audits));
            }
            if let Some(text) = new_final_message {
                final_message = Some(text);
            }

            // ---- no tool calls → finish -------------------------------------------
            if let Some(finish) = self.handle_no_tool_calls(&completion, step) {
                return Ok(self.make_outcome(
                    finish,
                    step,
                    final_message,
                    total_usage,
                    tool_audits,
                ));
            }

            self.push_message(Message::assistant_with_tool_calls(
                completion.content.clone(),
                completion.tool_calls.clone(),
            ));
            self.attach_reasoning_content(completion.reasoning_content.clone());

            for call in &completion.tool_calls {
                self.emit(AgentEvent::ToolCall {
                    name: call.name.clone(),
                    id: call.id.clone(),
                    arguments: call.arguments.to_string(),
                    step,
                });
            }

            // ---- tool execution ---------------------------------------------------
            let results = self.execute_tool_calls(&completion.tool_calls).await;

            if let Some(finish) = self.process_tool_results(
                &results,
                step,
                &mut recent_errors,
                &mut tool_audits,
                &mut skill_injector,
            ) {
                return Ok(self.make_outcome(
                    finish,
                    step,
                    final_message,
                    total_usage,
                    tool_audits,
                ));
            }
        }

        warn!(target: "recursive::agent", "step budget exceeded");
        let finish = FinishReason::BudgetExceeded;
        let steps = self.max_steps;
        self.emit(AgentEvent::TurnFinished {
            reason: finish_reason_str(&finish),
            steps,
        });
        Ok(self.make_outcome(finish, steps, final_message, total_usage, tool_audits))
    }
}

/// Step cap for the agent loop. `0` means unlimited (no `BudgetExceeded`).
///
/// If the `RECURSIVE_HARD_STEP_CAP` env var is set to a positive integer,
/// the cap is clamped to that value regardless of `max_steps`. This lets
/// operators enforce a production ceiling on `recursive loop` long-running
/// sessions (which default to `max_steps=0` / unbounded) without changing
/// the per-session contract. When unset or zero, behaviour is unchanged:
/// `max_steps=0` still means `usize::MAX`.
fn effective_step_limit(max_steps: usize) -> usize {
    let requested = if max_steps == 0 {
        usize::MAX
    } else {
        max_steps
    };
    match hard_step_cap_from_env() {
        Some(cap) if cap > 0 => requested.min(cap),
        _ => requested,
    }
}

/// Read the `RECURSIVE_HARD_STEP_CAP` env var once per call. Returns
/// `None` when unset or unparseable. A value of `0` is treated as
/// "unset" (matches the `max_steps=0` unlimited convention).
fn hard_step_cap_from_env() -> Option<usize> {
    std::env::var("RECURSIVE_HARD_STEP_CAP")
        .ok()
        .and_then(|s| s.parse::<usize>().ok())
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::{
        effective_step_limit, estimate_prompt_tokens, estimate_tokens_by_bytes, finish_reason_str,
        RunCore, StaticBreakdownCache, MIN_TRIM_LENGTH, TRIM_PLACEHOLDER,
    };
    use crate::message::Message;

    // The three `effective_step_limit_*` tests mutate the process-global
    // `RECURSIVE_HARD_STEP_CAP` env var. Running in parallel they race each
    // other (one test's set_var lands while another is mid-assert), so the
    // env access must be serialised. This lock is held only by those tests.
    static HARD_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    #[test]
    fn effective_step_limit_zero_means_unbounded() {
        let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        // Env var must be unset for this test — its presence would clamp.
        let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
        std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
        assert_eq!(effective_step_limit(0), usize::MAX);
        assert_eq!(effective_step_limit(32), 32);
        if let Some(v) = orig {
            std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
        }
    }

    #[test]
    fn effective_step_limit_respects_hard_cap_when_set() {
        let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
        std::env::set_var("RECURSIVE_HARD_STEP_CAP", "1000");

        // Unlimited path is clamped to the cap.
        assert_eq!(effective_step_limit(0), 1000);
        // Limited path below the cap stays as-is.
        assert_eq!(effective_step_limit(32), 32);
        // Limited path above the cap is clamped.
        assert_eq!(effective_step_limit(5000), 1000);

        if let Some(v) = orig {
            std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
        } else {
            std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
        }
    }

    #[test]
    fn effective_step_limit_ignores_invalid_hard_cap() {
        let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
        std::env::set_var("RECURSIVE_HARD_STEP_CAP", "not-a-number");
        assert_eq!(effective_step_limit(0), usize::MAX);
        std::env::set_var("RECURSIVE_HARD_STEP_CAP", "0");
        // Cap value of 0 is treated as "unset" → unlimited preserved.
        assert_eq!(effective_step_limit(0), usize::MAX);
        if let Some(v) = orig {
            std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
        } else {
            std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
        }
    }

    /// Verify the stuck-detection window/rate math that RunCore uses.
    /// This tests the same logic as the sliding-window check in run_inner().
    #[test]
    fn stuck_detection_window_and_rate() {
        // Simulate: stuck_window=3, stuck_error_rate=1.0 → 3 consecutive errors triggers stuck
        let stuck_window = 3usize;
        let stuck_error_rate = 1.0f64;

        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
            std::collections::VecDeque::with_capacity(stuck_window);

        // Push 2 errors — should not trigger yet
        for i in 0..2 {
            if recent_errors.len() == stuck_window {
                recent_errors.pop_front();
            }
            let name = format!("tool_{i}");
            recent_errors.push_back((true, name));
            let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64
                / recent_errors.len() as f64;
            assert!(
                recent_errors.len() < stuck_window || rate < stuck_error_rate,
                "should not trigger before window is full"
            );
        }

        // Push 3rd error — window is now full and rate = 1.0 ≥ threshold
        if recent_errors.len() == stuck_window {
            recent_errors.pop_front();
        }
        recent_errors.push_back((true, "tool_2".to_string()));
        assert_eq!(recent_errors.len(), stuck_window);
        let error_count = recent_errors.iter().filter(|(e, _)| *e).count();
        let rate = error_count as f64 / stuck_window as f64;
        assert!(
            rate >= stuck_error_rate,
            "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
        );

        // Verify most-repeated tool reporting logic
        let error_entries: Vec<&str> = recent_errors
            .iter()
            .filter(|(e, _)| *e)
            .map(|(_, n)| n.as_str())
            .collect();
        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
        for n in &error_entries {
            *counts.entry(n).or_default() += 1;
        }
        let top_tool = counts.into_iter().max_by_key(|&(_, c)| c).map(|(n, _)| n);
        // All three are distinct so any one could be "top"; just verify it exists
        assert!(top_tool.is_some());
    }

    #[test]
    fn stuck_detection_partial_errors_below_threshold() {
        // window=4, rate=0.8 → need 4 errors out of 4 (or round up).
        // With 3 errors + 1 success the rate is 0.75 < 0.8, so no trigger.
        let stuck_window = 4usize;
        let stuck_error_rate = 0.8f64;

        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
            std::collections::VecDeque::with_capacity(stuck_window);

        let pattern = [
            (true, "tool_a"),
            (true, "tool_b"),
            (true, "tool_a"),
            (false, "tool_c"),
        ]; // 3/4 errors = 0.75
        for (is_error, name) in &pattern {
            if recent_errors.len() == stuck_window {
                recent_errors.pop_front();
            }
            recent_errors.push_back((*is_error, name.to_string()));
        }

        assert_eq!(recent_errors.len(), stuck_window);
        let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64 / stuck_window as f64;
        assert!(
            rate < stuck_error_rate,
            "rate {rate} should be below threshold {stuck_error_rate}"
        );
    }

    #[test]
    fn stuck_detection_reports_most_repeated_tool() {
        // window=4, rate=0.75, pattern: tool_a err, tool_b err, tool_a err, tool_b ok
        // error_count=3, rate=0.75, most repeated should be tool_a (appears 2× in errors)
        let stuck_window = 4usize;
        let stuck_error_rate = 0.75f64;

        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
            std::collections::VecDeque::with_capacity(stuck_window);

        let pattern = [
            (true, "tool_a"),
            (true, "tool_b"),
            (true, "tool_a"),
            (false, "tool_b"),
        ];
        for (is_error, name) in &pattern {
            if recent_errors.len() == stuck_window {
                recent_errors.pop_front();
            }
            recent_errors.push_back((*is_error, name.to_string()));
        }

        assert_eq!(recent_errors.len(), stuck_window);

        let error_entries: Vec<&str> = recent_errors
            .iter()
            .filter(|(is_err, _)| *is_err)
            .map(|(_, n)| n.as_str())
            .collect();
        let error_count = error_entries.len();
        let rate = error_count as f64 / stuck_window as f64;
        assert!(
            rate >= stuck_error_rate,
            "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
        );

        // Find the most frequently appearing tool name in the error window.
        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
        for n in &error_entries {
            *counts.entry(n).or_default() += 1;
        }
        let top_tool = counts
            .into_iter()
            .max_by_key(|&(_, c)| c)
            .map(|(n, _)| n)
            .unwrap();
        assert_eq!(
            top_tool, "tool_a",
            "most repeated tool should be tool_a (2 errors), not tool_b (1 error)"
        );
    }

    // Helper: build a minimal RunCore suitable for testing methods that only
    // touch `messages` (e.g. `attach_reasoning_content`).
    fn make_test_core<'a>(
        messages: Vec<Message>,
        hooks: &'a crate::hooks::HookRegistry,
    ) -> RunCore<'a> {
        use std::sync::atomic::AtomicBool;
        use std::sync::Arc;
        RunCore {
            messages: Arc::new(messages),
            llm: Arc::new(crate::llm::MockProvider::new(vec![])),
            tools: Arc::new(crate::tools::ToolRegistry::default()),
            max_steps: 1,
            max_transcript_chars: None,
            events: None,
            streaming: false,
            compactor: None,
            microcompactor: None,
            permission_hook: None,
            hooks,
            total_llm_latency_ms: 0,
            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
            shutdown_token: None,
            mailbox: None,
            stuck_window: 3,
            stuck_error_rate: 1.0,
            turn: 0,
            globs_skills: vec![],
            prompt_segments: None,
            static_breakdown: StaticBreakdownCache::default(),
            last_prompt_tokens: 0,
            consecutive_compact_failures: 0,
            wall_timeout_secs: 0,
            wall_start: None,
        }
    }

    #[test]
    fn attach_reasoning_content_sets_last_message_when_some() {
        let hooks = crate::hooks::HookRegistry::new();
        let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);

        core.attach_reasoning_content(Some("I thought carefully about this.".to_string()));

        assert_eq!(
            core.messages.last().unwrap().reasoning_content.as_deref(),
            Some("I thought carefully about this."),
            "reasoning_content should be set on the last message"
        );
    }

    #[test]
    fn attach_reasoning_content_does_not_modify_when_none() {
        let hooks = crate::hooks::HookRegistry::new();
        let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);

        core.attach_reasoning_content(None);

        assert!(
            core.messages.last().unwrap().reasoning_content.is_none(),
            "reasoning_content should remain None when called with None"
        );
    }

    #[test]
    fn attach_reasoning_content_preserves_existing_content_when_none() {
        let hooks = crate::hooks::HookRegistry::new();
        let mut msg = Message::assistant("response".to_string());
        msg.reasoning_content = Some("prior thinking".to_string());
        let mut core = make_test_core(vec![msg], &hooks);

        core.attach_reasoning_content(None);

        assert_eq!(
            core.messages.last().unwrap().reasoning_content.as_deref(),
            Some("prior thinking"),
            "calling with None should not overwrite existing reasoning_content"
        );
    }

    // ========================================================================
    // maybe_trim_transcript tests
    // ========================================================================

    // ========================================================================
    // finish_reason_str tests
    // (kills String::new() and "xyzzy" function-level mutants)
    // ========================================================================

    #[test]
    fn finish_reason_str_is_nonempty_and_not_xyzzy() {
        use crate::agent::FinishReason;
        let reason = FinishReason::NoMoreToolCalls;
        let s = finish_reason_str(&reason);
        assert!(
            !s.is_empty(),
            "finish_reason_str must not return empty string"
        );
        assert_ne!(s, "xyzzy", "finish_reason_str must not return 'xyzzy'");
    }

    #[test]
    fn finish_reason_str_matches_display() {
        use crate::agent::FinishReason;
        let reason = FinishReason::BudgetExceeded;
        let s = finish_reason_str(&reason);
        assert_eq!(
            s,
            reason.to_string(),
            "finish_reason_str must delegate to Display"
        );
    }

    // ========================================================================
    // estimate_prompt_tokens / fallback-usage tests
    // (cover the else branch in dispatch_llm_step used when the provider
    //  does not report `usage`)
    // ========================================================================

    #[test]
    fn estimate_prompt_tokens_divides_total_chars_by_four() {
        // 3 messages: 4 + 8 + 12 = 24 chars → 24 / 4 = 6 tokens.
        let messages = vec![
            Message::user("abcd".to_string()),           // 4
            Message::assistant("abcdefgh".to_string()),  // 8
            Message::system("abcdefghijkl".to_string()), // 12
        ];
        assert_eq!(
            estimate_prompt_tokens(&messages),
            6,
            "24 total chars / 4 = 6 tokens"
        );
    }

    #[test]
    fn estimate_prompt_tokens_truncates_partial_tokens() {
        // 7 chars → 7 / 4 = 1 (integer truncation), not rounded up to 2.
        let messages = vec![Message::user("abcdefg".to_string())];
        assert_eq!(
            estimate_prompt_tokens(&messages),
            1,
            "integer division must truncate 7/4 to 1"
        );
    }

    #[test]
    fn estimate_prompt_tokens_floors_to_one_for_empty_messages() {
        // No messages → 0 / 4 = 0, but .max(1) floors to 1 so the gauge
        // never reports zero (the system prompt always exists).
        let messages: Vec<Message> = vec![];
        assert_eq!(
            estimate_prompt_tokens(&messages),
            1,
            "empty transcript must still report at least 1 token"
        );
    }

    #[test]
    fn estimate_prompt_tokens_floors_to_one_for_sub_four_chars() {
        // 3 chars → 3 / 4 = 0, floored to 1 by .max(1).
        let messages = vec![Message::user("abc".to_string())];
        assert_eq!(
            estimate_prompt_tokens(&messages),
            1,
            "content shorter than 4 chars must floor to 1 token"
        );
    }

    // Helper: build a RunCore wired to a custom provider and event sender so
    // tests can drive `dispatch_llm_step` and inspect emitted events.
    fn make_core_with_llm_and_events<'a>(
        messages: Vec<Message>,
        llm: Arc<dyn crate::llm::ChatProvider>,
        events: tokio::sync::mpsc::UnboundedSender<crate::event::AgentEvent>,
        hooks: &'a crate::hooks::HookRegistry,
    ) -> RunCore<'a> {
        use std::sync::atomic::AtomicBool;
        RunCore {
            messages: Arc::new(messages),
            llm,
            tools: Arc::new(crate::tools::ToolRegistry::default()),
            max_steps: 1,
            max_transcript_chars: None,
            events: Some(events),
            streaming: false,
            compactor: None,
            microcompactor: None,
            permission_hook: None,
            hooks,
            total_llm_latency_ms: 0,
            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
            shutdown_token: None,
            mailbox: None,
            stuck_window: 3,
            stuck_error_rate: 1.0,
            turn: 0,
            globs_skills: vec![],
            prompt_segments: None,
            static_breakdown: StaticBreakdownCache::default(),
            last_prompt_tokens: 0,
            consecutive_compact_failures: 0,
            wall_timeout_secs: 0,
            wall_start: None,
        }
    }

    /// When the provider returns a completion without `usage`, the fallback
    /// branch must emit a best-effort estimated `Usage` event: input from the
    /// transcript chars, output from `content` + `reasoning_content`, both
    /// divided by 4, with zero cache tokens — and it must NOT accumulate into
    /// `total_usage` (only provider-reported usage does).
    #[tokio::test]
    async fn dispatch_llm_step_emits_estimated_usage_when_provider_reports_none() {
        use crate::event::AgentEvent;
        use crate::llm::{Completion, MockProvider, TokenUsage};

        let hooks = crate::hooks::HookRegistry::new();
        // Transcript: a single user message of exactly 40 bytes → 40/4 = 10.
        let messages = vec![Message::user("x".repeat(40))];

        // Completion WITHOUT usage forces the fallback branch.
        // content = 8 bytes, reasoning = 4 bytes → 12/4 = 3 output tokens.
        let completion = Completion {
            content: "12345678".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: Some("abcd".to_string()),
        };
        let llm = Arc::new(MockProvider::new(vec![completion]));

        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_core_with_llm_and_events(messages, llm, tx, &hooks);

        let specs: Vec<crate::llm::ToolSpec> = vec![];
        let mut total_usage = TokenUsage::default();
        core.dispatch_llm_step(&specs, 0, &mut total_usage)
            .await
            .expect("dispatch_llm_step should succeed");

        // Estimated usage must not be folded into total_usage.
        assert_eq!(
            total_usage.prompt_tokens, 0,
            "estimate must not accumulate input into total_usage"
        );
        assert_eq!(
            total_usage.completion_tokens, 0,
            "estimate must not accumulate output into total_usage"
        );

        // Locate the emitted Usage event.
        let mut usage_event = None;
        while let Ok(ev) = rx.try_recv() {
            if matches!(ev, AgentEvent::Usage { .. }) {
                usage_event = Some(ev);
                break;
            }
        }
        match usage_event.expect("fallback branch must emit a Usage event") {
            AgentEvent::Usage {
                input_tokens,
                output_tokens,
                cache_hit_tokens,
                cache_miss_tokens,
                step,
            } => {
                assert_eq!(input_tokens, 10, "input estimate = 40 chars / 4");
                assert_eq!(output_tokens, 3, "output estimate = (8 + 4) chars / 4");
                assert_eq!(cache_hit_tokens, 0, "estimate reports no cache hits");
                assert_eq!(cache_miss_tokens, 0, "estimate reports no cache misses");
                assert_eq!(step, 0, "step must be forwarded unchanged");
            }
            other => panic!("expected Usage event, got {other:?}"),
        }
    }

    // ========================================================================
    // maybe_trim_transcript tests
    // ========================================================================

    #[test]
    fn maybe_trim_does_nothing_when_under_limit() {
        let hooks = crate::hooks::HookRegistry::new();
        // Use a large tool result (> MIN_TRIM_LENGTH) but keep limit ABOVE total bytes.
        // This kills the `replace < with ==` mutant: with `==`, bytes != limit means
        // the early-return is skipped and the tool result would be incorrectly trimmed.
        let large_output = "x".repeat(MIN_TRIM_LENGTH + 10);
        let messages = vec![
            Message::system("sys".to_string()),
            Message::tool_result("c1", &large_output),
        ];
        let orig_content = messages[1].content.clone();
        let total_bytes = "sys".len() + large_output.len();
        let mut core = make_test_core(messages, &hooks);

        // Set limit strictly ABOVE total bytes (not equal), so trimming must not fire.
        let limit = total_bytes + 1;
        core.maybe_trim_transcript(limit, 0);

        assert_eq!(
            core.messages[1].content, orig_content,
            "content must not be touched when total bytes < limit"
        );
    }

    #[test]
    fn enforce_transcript_budget_returns_none_when_under_limit() {
        // kills `replace < with == in enforce_transcript_budget` mutation.
        // If changed to `==`, a core with chars clearly under the limit would
        // return Some (falsely claiming budget exceeded) instead of None.
        use crate::llm::TokenUsage;
        let hooks = crate::hooks::HookRegistry::new();
        // One short message: "hi" = 2 chars
        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
        // Set limit to 1000 — far above 2 chars
        core.max_transcript_chars = Some(1000);
        let usage = TokenUsage::default();
        let result = core.enforce_transcript_budget(0, &usage);
        assert!(
            result.is_none(),
            "chars (2) < limit (1000) must return None; got Some(_)"
        );
    }

    #[test]
    fn enforce_transcript_budget_returns_finish_reason_when_over_limit() {
        // Validates the >-limit path: when chars >= limit, must return Some with TranscriptLimit.
        use crate::agent::FinishReason;
        use crate::llm::TokenUsage;
        let hooks = crate::hooks::HookRegistry::new();
        // Message with 100 'x' chars — well above a limit of 10
        let msg = Message::user("x".repeat(100));
        let mut core = make_test_core(vec![msg], &hooks);
        core.max_transcript_chars = Some(10);
        let usage = TokenUsage::default();
        let result = core.enforce_transcript_budget(0, &usage);
        assert!(
            result.is_some(),
            "chars (100) >= limit (10) must return Some finish reason"
        );
        let (finish, _step) = result.unwrap();
        assert!(
            matches!(finish, FinishReason::TranscriptLimit { .. }),
            "finish reason must be TranscriptLimit; got {finish:?}"
        );
    }

    #[test]
    fn maybe_trim_replaces_large_tool_result_when_over_limit() {
        let hooks = crate::hooks::HookRegistry::new();
        // A tool result with content > MIN_TRIM_LENGTH (200) bytes
        let large_output = "x".repeat(MIN_TRIM_LENGTH + 100);
        let messages = vec![
            Message::system("sys".to_string()),
            Message::tool_result("c1", &large_output),
        ];
        let mut core = make_test_core(messages, &hooks);

        // Set limit below total content so trimming fires
        core.maybe_trim_transcript(10, 0);

        assert_eq!(
            core.messages[1].content, TRIM_PLACEHOLDER,
            "large tool result must be replaced with placeholder"
        );
    }

    #[test]
    fn maybe_trim_does_not_trim_small_tool_results() {
        let hooks = crate::hooks::HookRegistry::new();
        // A tool result shorter than MIN_TRIM_LENGTH
        let small_output = "small".to_string();
        let messages = vec![
            Message::system("sys".to_string()),
            Message::tool_result("c1", &small_output),
        ];
        let mut core = make_test_core(messages, &hooks);

        // Trigger trim (limit = 0)
        core.maybe_trim_transcript(0, 0);

        assert_eq!(
            core.messages[1].content, small_output,
            "short tool result must not be trimmed (< MIN_TRIM_LENGTH)"
        );
    }

    #[test]
    fn maybe_trim_does_not_trim_exactly_min_trim_length() {
        // Kills the `replace > with >=` mutant: content.len() == MIN_TRIM_LENGTH
        // must NOT be trimmed under the strict `>` check.
        let hooks = crate::hooks::HookRegistry::new();
        let exact_output = "z".repeat(MIN_TRIM_LENGTH);
        let messages = vec![
            Message::system("s".to_string()),
            Message::tool_result("c1", &exact_output),
        ];
        let mut core = make_test_core(messages, &hooks);
        // Set limit = 0 so trimming is attempted
        core.maybe_trim_transcript(0, 0);
        assert_eq!(
            core.messages[1].content, exact_output,
            "content exactly at MIN_TRIM_LENGTH must NOT be trimmed (> is strict)"
        );
    }

    #[test]
    fn maybe_trim_trims_one_more_than_min_trim_length() {
        // Complementary to the above: content.len() == MIN_TRIM_LENGTH + 1 MUST be trimmed.
        let hooks = crate::hooks::HookRegistry::new();
        let just_over = "z".repeat(MIN_TRIM_LENGTH + 1);
        let messages = vec![
            Message::system("s".to_string()),
            Message::tool_result("c1", &just_over),
        ];
        let mut core = make_test_core(messages, &hooks);
        core.maybe_trim_transcript(0, 0);
        assert_eq!(
            core.messages[1].content, TRIM_PLACEHOLDER,
            "content one byte over MIN_TRIM_LENGTH must be trimmed"
        );
    }

    #[test]
    fn maybe_trim_stops_after_fitting_under_limit() {
        let hooks = crate::hooks::HookRegistry::new();
        // Two large tool results; only trim until we go under the limit.
        let large1 = "a".repeat(MIN_TRIM_LENGTH + 100);
        let large2 = "b".repeat(MIN_TRIM_LENGTH + 100);
        let messages = vec![
            Message::system("sys".to_string()),
            Message::tool_result("c1", &large1),
            Message::tool_result("c2", &large2),
        ];
        let mut core = make_test_core(messages, &hooks);

        // Set limit just above placeholder_len so trimming the first result suffices.
        let placeholder_len = TRIM_PLACEHOLDER.len();
        let limit = 3 + placeholder_len + large2.len() + 50; // sys + placeholder + large2 + buffer
        core.maybe_trim_transcript(limit, 0);

        assert_eq!(
            core.messages[1].content, TRIM_PLACEHOLDER,
            "first large result should be trimmed"
        );
        assert_eq!(
            core.messages[2].content, large2,
            "second result should not be touched once we're under limit"
        );
    }

    #[test]
    fn maybe_trim_trims_when_chars_exactly_equals_limit() {
        // Kills: `replace < with <=` at line 196.
        //
        // When chars == limit the original `<` guard is FALSE → trimming proceeds.
        // With `<=`, it would be TRUE → early return, nothing is trimmed.
        let hooks = crate::hooks::HookRegistry::new();
        // Build a transcript whose total content length == limit exactly.
        let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); // 250 chars
        let messages = vec![
            Message::system("s".to_string()),          // 1 char
            Message::tool_result("c1", &tool_content), // 250 chars
        ];
        // Total = 1 + 250 = 251.  Set limit = 251 so chars == limit.
        let limit = 1 + tool_content.len();
        let mut core = make_test_core(messages, &hooks);
        core.maybe_trim_transcript(limit, 0);

        assert_eq!(
            core.messages[1].content, TRIM_PLACEHOLDER,
            "tool result must be trimmed when chars == limit (not strictly less)"
        );
    }

    #[test]
    fn maybe_trim_continues_when_chars_equals_limit_after_first_trim() {
        // Kills: `replace < with <=` at line 211 (the inner break condition).
        //
        // After trimming the first tool result, if the remaining chars == limit,
        // the original `<` is FALSE → the loop continues and trims the second tool result.
        // With `<=`, it would be TRUE → break, leaving the second tool result untrimmed.
        let hooks = crate::hooks::HookRegistry::new();

        let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); // 250 chars
        let messages = vec![
            Message::system("s".to_string()),          // 1 char
            Message::tool_result("c1", &tool_content), // 250 chars (will be trimmed first)
            Message::tool_result("c2", &tool_content), // 250 chars (must ALSO be trimmed)
        ];
        // Total = 1 + 250 + 250 = 501
        // TRIM_PLACEHOLDER.len() = 40
        // After trimming tool1: 501 - 250 + 40 = 291
        // Set limit = 291 so that after trimming tool1, chars == limit (not < limit).
        // Original `<`: 291 < 291 → false → continues trimming tool2.
        // Mutant  `<=`: 291 <= 291 → true  → breaks, tool2 NOT trimmed.
        let after_first_trim = 1 + TRIM_PLACEHOLDER.len() + tool_content.len();
        let limit = after_first_trim; // exact boundary

        let mut core = make_test_core(messages, &hooks);
        core.maybe_trim_transcript(limit, 0);

        assert_eq!(
            core.messages[1].content, TRIM_PLACEHOLDER,
            "first tool result must be trimmed"
        );
        assert_eq!(
            core.messages[2].content, TRIM_PLACEHOLDER,
            "second tool result must ALSO be trimmed when chars == limit after first trim"
        );
    }

    #[test]
    fn maybe_trim_emits_event_when_trimmed() {
        // Kills: `replace += with *=` (trimmed_count stays 0 → event not emitted)
        //        `replace > with ==/</>= in if trimmed_count > 0` (wrong guard)
        use crate::event::AgentEvent;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let large_output = "x".repeat(MIN_TRIM_LENGTH + 50);
        let messages = vec![
            Message::system("sys".to_string()),
            Message::tool_result("c1", &large_output),
        ];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_test_core(messages, &hooks);
        core.events = Some(tx);

        // Trim fires (limit = 0 → chars > 0 = limit)
        core.maybe_trim_transcript(0, 0);

        // The emit must have sent an AssistantText event containing the trim notice
        let event = rx
            .try_recv()
            .expect("an event must have been emitted after trimming");
        match event {
            AgentEvent::AssistantText { text, .. } => {
                assert!(
                    text.contains("trimmed"),
                    "trim event text must mention 'trimmed'; got: {text}"
                );
            }
            other => panic!("expected AssistantText but got: {other:?}"),
        }
    }

    #[test]
    fn maybe_trim_no_event_when_nothing_trimmed() {
        // Complementary: no event when nothing is trimmed (trimmed_count == 0)
        use crate::event::AgentEvent;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let small_output = "tiny".to_string(); // Below MIN_TRIM_LENGTH
        let messages = vec![
            Message::system("sys".to_string()),
            Message::tool_result("c1", &small_output),
        ];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_test_core(messages, &hooks);
        core.events = Some(tx);

        // Limit = 0 → trimming is attempted, but nothing qualifies (< MIN_TRIM_LENGTH)
        core.maybe_trim_transcript(0, 0);

        assert!(
            rx.try_recv().is_err(),
            "no event must be emitted when nothing was trimmed"
        );
    }

    // ========================================================================
    // maybe_compact tests
    // ========================================================================

    #[tokio::test]
    async fn maybe_compact_noop_when_no_compactor() {
        let hooks = crate::hooks::HookRegistry::new();
        let messages = vec![
            Message::user("q".to_string()),
            Message::assistant("a".to_string()),
        ];
        let mut core = make_test_core(messages, &hooks);
        // compactor is None by default in make_test_core
        core.maybe_compact(0).await;
        assert_eq!(core.messages.len(), 2, "messages must not change");
    }

    #[tokio::test]
    async fn maybe_compact_noop_when_under_threshold() {
        use crate::compact::Compactor;
        let hooks = crate::hooks::HookRegistry::new();
        let messages = vec![
            Message::user("hi".to_string()),
            Message::assistant("hello".to_string()),
        ];
        let mut core = make_test_core(messages, &hooks);
        // Set a threshold much larger than the transcript
        core.compactor = Some(Compactor::new(usize::MAX));

        core.maybe_compact(0).await;
        assert_eq!(core.messages.len(), 2, "no compaction under threshold");
    }

    #[tokio::test]
    async fn maybe_compact_fires_when_over_threshold() {
        use crate::compact::Compactor;
        use crate::llm::{Completion, MockProvider};
        let hooks = crate::hooks::HookRegistry::new();
        // Build a transcript that exceeds the threshold
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("msg1".to_string()),
            Message::assistant("rep1".to_string()),
            Message::user("msg2".to_string()),
            Message::assistant("rep2".to_string()),
            Message::user("msg3".to_string()),
        ];
        let provider = Arc::new(MockProvider::new(vec![Completion {
            content: "compact summary".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: None,
        }]));
        let mut core = make_test_core(messages, &hooks);
        core.llm = provider;
        // Threshold = 0 → always compact
        core.compactor = Some(Compactor::new(0).keep_recent_n(2));

        core.maybe_compact(1).await;
        // After compaction the transcript starts with a compaction summary
        assert!(
            core.messages[0].is_compaction_summary,
            "first message must be a compaction summary after compaction"
        );
    }

    #[tokio::test]
    async fn maybe_compact_fires_at_threshold_boundary() {
        // Kills: `replace < with <=` mutant at line 239.
        // With `<=`, when chars == threshold_chars, the function returns early instead of compacting.
        use crate::compact::Compactor;
        use crate::llm::{Completion, MockProvider};
        let hooks = crate::hooks::HookRegistry::new();
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("msg1".to_string()),
            Message::assistant("rep1".to_string()),
            Message::user("msg2".to_string()),
            Message::assistant("rep2".to_string()),
            Message::user("last".to_string()),
        ];
        // Compute exact bytes
        let bytes = crate::compact::Compactor::estimate_bytes(&messages);
        let provider = Arc::new(MockProvider::new(vec![Completion {
            content: "summary".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: None,
        }]));
        let mut core = make_test_core(messages, &hooks);
        core.llm = provider;
        // Set threshold = exact bytes → `bytes < threshold` is false → compaction fires
        // With `< → <=`: `bytes <= bytes` is true → returns early → no compaction
        core.compactor = Some(Compactor::new(bytes).keep_recent_n(2));

        core.maybe_compact(1).await;
        assert!(
            core.messages[0].is_compaction_summary,
            "compaction must fire when bytes == threshold (bytes is NOT less than threshold)"
        );
    }

    #[tokio::test]
    async fn maybe_compact_uses_token_threshold_intra_turn() {
        // Goal-330 regression: intra-turn maybe_compact must use the
        // token-based threshold when `last_prompt_tokens` is available,
        // even if the char threshold is high enough that it alone would
        // NOT fire.
        use crate::compact::Compactor;
        use crate::llm::{Completion, MockProvider};
        let hooks = crate::hooks::HookRegistry::new();
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("msg1".to_string()),
            Message::assistant("rep1".to_string()),
            Message::user("msg2".to_string()),
            Message::assistant("rep2".to_string()),
            Message::user("msg3".to_string()),
        ];
        let provider = Arc::new(MockProvider::new(vec![Completion {
            content: "token-threshold triggered summary".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: None,
        }]));
        let mut core = make_test_core(messages, &hooks);
        core.llm = provider;
        // Set high char threshold (would NOT fire on this transcript)
        // and low token threshold (WILL fire when last_prompt_tokens is set).
        let bytes = crate::compact::Compactor::estimate_bytes(&core.messages);
        core.compactor = Some(
            Compactor::new(bytes + 1000) // char threshold too high
                .threshold_prompt_tokens(500) // low token threshold
                .keep_recent_n(2),
        );
        // Set last_prompt_tokens above the token threshold.
        core.last_prompt_tokens = 500;

        core.maybe_compact(1).await;
        assert!(
            core.messages[0].is_compaction_summary,
            "compaction must fire via token threshold even though chars are below char threshold"
        );
    }

    /// Kills: `delete ! in RunCore<'a>::execute_tool_calls` at line 306.
    ///
    /// When `exploring_plan_mode == false` and the registry's mode is Plan,
    /// calling any tool (other than enter/exit plan mode) must return an error
    /// asking the agent to call `enter_plan_mode` first.
    ///
    /// With the mutant (`!` deleted), the condition becomes:
    ///   `if self.exploring_plan_mode.load() && …`
    /// which is FALSE when not in plan mode, so the guard is skipped and the
    /// tool call proceeds instead of returning the expected error.
    #[tokio::test]
    async fn execute_tool_calls_plan_mode_guard_when_not_exploring() {
        use crate::llm::ToolCall;
        use crate::permissions::{PermissionMode, PermissionsConfig};
        use std::sync::atomic::AtomicBool;

        let hooks = crate::hooks::HookRegistry::new();
        let tools = Arc::new(crate::tools::ToolRegistry::default().with_permissions(
            PermissionsConfig {
                mode: PermissionMode::Plan {
                    pre_plan_mode: Box::new(PermissionMode::Default),
                    bypass_available: false,
                },
                layers: vec![],
            },
        ));

        let mut core = make_test_core(vec![], &hooks);
        core.tools = tools;
        // Explicitly not in plan-mode exploration.
        core.exploring_plan_mode = Arc::new(AtomicBool::new(false));

        let call = ToolCall {
            id: "tc1".to_string(),
            name: "Write".to_string(),
            arguments: serde_json::json!({}),
        };

        let results = core.execute_tool_calls(&[call]).await;

        assert_eq!(results.len(), 1, "should produce exactly one outcome");
        assert!(
            results[0].result.contains("enter_plan_mode"),
            "must tell the agent to call enter_plan_mode first; got: {}",
            results[0].result,
        );
    }

    #[tokio::test]
    async fn maybe_compact_kept_count_is_correct() {
        // Kills: `replace - with + in maybe_compact` at line 253.
        // `kept = kept_before - removed` should equal the number of messages remaining.
        // With `- → +`, `kept = kept_before + removed` which would be wrong.
        use crate::compact::Compactor;
        use crate::event::AgentEvent;
        use crate::llm::{Completion, MockProvider};
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("msg1".to_string()),
            Message::assistant("rep1".to_string()),
            Message::user("msg2".to_string()),
            Message::assistant("rep2".to_string()),
            Message::user("last".to_string()),
        ];
        let kept_before = messages.len();
        let provider = Arc::new(MockProvider::new(vec![Completion {
            content: "summary".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: None,
        }]));
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_test_core(messages, &hooks);
        core.llm = provider;
        core.events = Some(tx);
        core.compactor = Some(Compactor::new(0).keep_recent_n(2));

        core.maybe_compact(1).await;

        // Drain events to find Compacted
        let mut found_compacted = false;
        while let Ok(ev) = rx.try_recv() {
            if let AgentEvent::Compacted { removed, kept, .. } = ev {
                assert_eq!(
                    kept + removed,
                    kept_before,
                    "kept + removed must equal the original message count"
                );
                // kept must be the actual remaining messages (after compaction, first is summary)
                assert_eq!(
                    kept,
                    kept_before - removed,
                    "kept must be kept_before - removed, not kept_before + removed"
                );
                found_compacted = true;
            }
        }
        assert!(found_compacted, "a Compacted event must have been emitted");
    }

    // ========================================================================
    // Circuit breaker tests (Goal 331)
    // ========================================================================

    #[tokio::test]
    async fn maybe_compact_circuit_breaker_skips_when_failures_exceed_threshold() {
        // When consecutive_compact_failures >= MAX_CONSECUTIVE_COMPACT_FAILURES,
        // maybe_compact must emit CircuitBreaker and return without compacting.
        use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
        use crate::event::AgentEvent::{Compacted, CompactionSkipped};
        use crate::event::CompactionSkipReason;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("long enough to trigger compaction".to_string()),
            Message::assistant("response".to_string()),
        ];
        let (tx, mut rx) = mpsc::unbounded_channel();
        let mut core = make_test_core(messages, &hooks);
        core.events = Some(tx);
        core.compactor = Some(Compactor::new(0)); // 0 → always compact
        core.consecutive_compact_failures = MAX_CONSECUTIVE_COMPACT_FAILURES;

        core.maybe_compact(0).await;

        // No Compacted event; only CompactionSkipped::CircuitBreaker
        let mut found_breaker = false;
        while let Ok(ev) = rx.try_recv() {
            match ev {
                Compacted { .. } => panic!("circuit breaker should prevent compaction"),
                CompactionSkipped { reason, .. } => {
                    assert_eq!(reason, CompactionSkipReason::CircuitBreaker);
                    found_breaker = true;
                }
                _ => {}
            }
        }
        assert!(found_breaker, "must emit CircuitBreaker skip event");
        assert_eq!(
            core.messages.len(),
            3,
            "must not compact when breaker is tripped"
        );
    }

    #[tokio::test]
    async fn maybe_compact_circuit_breaker_resets_on_success() {
        // After a successful compaction, consecutive_compact_failures must be 0.
        use crate::compact::Compactor;
        use crate::llm::{Completion, MockProvider};

        let hooks = crate::hooks::HookRegistry::new();
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("msg1".to_string()),
            Message::assistant("rep1".to_string()),
            Message::user("msg2".to_string()),
            Message::assistant("rep2".to_string()),
        ];
        let provider = Arc::new(MockProvider::new(vec![Completion {
            content: "summary".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: None,
        }]));
        let mut core = make_test_core(messages, &hooks);
        core.llm = provider;
        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
        core.consecutive_compact_failures = 2; // near threshold but not reached

        core.maybe_compact(1).await;

        assert_eq!(
            core.consecutive_compact_failures, 0,
            "breaker must reset on success"
        );
    }

    #[tokio::test]
    async fn maybe_compact_circuit_breaker_increments_on_error() {
        // When compaction returns an error, consecutive_compact_failures must increment.
        use crate::compact::Compactor;
        use crate::error::Error;
        use crate::event::AgentEvent::CompactionSkipped;
        use crate::event::CompactionSkipReason;
        use crate::llm::MockProvider;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        // Need at least 2 conversational msgs in "older" (pre-keep_recent_n) for compaction to fire.
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("msg1".to_string()),
            Message::assistant("rep1".to_string()),
            Message::user("msg2".to_string()),
            Message::assistant("rep2".to_string()),
        ];
        // MockProvider that errors on the first complete() call → compaction fails.
        let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![Error::Llm {
            provider: "mock".into(),
            message: "simulated compaction failure".into(),
        }]));
        let (tx, mut rx) = mpsc::unbounded_channel();
        let mut core = make_test_core(messages, &hooks);
        core.llm = provider;
        core.events = Some(tx);
        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
        core.consecutive_compact_failures = 0;

        core.maybe_compact(1).await;

        assert_eq!(
            core.consecutive_compact_failures, 1,
            "breaker must increment on error"
        );
        // Must emit CompactionSkipped::Error
        let mut found_error = false;
        while let Ok(ev) = rx.try_recv() {
            if let CompactionSkipped { reason, .. } = ev {
                assert_eq!(reason, CompactionSkipReason::Error);
                found_error = true;
            }
        }
        assert!(found_error, "must emit CompactionSkipped::Error event");
    }

    #[tokio::test]
    async fn maybe_compact_circuit_breaker_accumulates_to_threshold() {
        // Three consecutive failures must trip the breaker (MAX_CONSECUTIVE_COMPACT_FAILURES = 3).
        use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
        use crate::error::Error;
        use crate::event::AgentEvent::{Compacted, CompactionSkipped};
        use crate::event::CompactionSkipReason;
        use crate::llm::MockProvider;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        // Need at least 2 conversational msgs in older for compaction to fire.
        let messages = vec![
            Message::system("sys".to_string()),
            Message::user("msg1".to_string()),
            Message::assistant("rep1".to_string()),
            Message::user("msg2".to_string()),
            Message::assistant("rep2".to_string()),
        ];
        // MockProvider errors on every complete() call → compaction always fails.
        let mk_err = || Error::Llm {
            provider: "mock".into(),
            message: "simulated compaction failure".into(),
        };
        let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![
            mk_err(),
            mk_err(),
            mk_err(),
            mk_err(),
        ]));
        let (tx, mut rx) = mpsc::unbounded_channel();
        let mut core = make_test_core(messages, &hooks);
        core.llm = provider.clone();
        core.events = Some(tx);
        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
        core.consecutive_compact_failures = 0;

        // First two calls: each increments the counter
        for step in 0..(MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize {
            core.maybe_compact(step).await;
        }
        assert_eq!(
            core.consecutive_compact_failures,
            MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
            "after {} failures, counter should be {}",
            MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
            MAX_CONSECUTIVE_COMPACT_FAILURES - 1
        );

        // Third call: reaches threshold → breaker trips, no compaction attempted
        // Drain events before third call
        while rx.try_recv().is_ok() {}
        core.maybe_compact((MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize)
            .await;

        // Counter stays at threshold (doesn't overflow)
        assert_eq!(
            core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
            "counter must not exceed threshold"
        );

        // Fourth call: breaker already tripped → emits CircuitBreaker, no counter change
        let (tx2, mut rx2) = mpsc::unbounded_channel();
        core.events = Some(tx2);
        core.maybe_compact(MAX_CONSECUTIVE_COMPACT_FAILURES as usize)
            .await;

        assert_eq!(
            core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
            "breaker tripped: counter should stay at max"
        );

        // Must have emitted CircuitBreaker (no further increments)
        let mut breaker_count = 0;
        while let Ok(ev) = rx2.try_recv() {
            if let CompactionSkipped { ref reason, .. } = ev {
                assert_eq!(*reason, CompactionSkipReason::CircuitBreaker);
                breaker_count += 1;
            }
            if let Compacted { .. } = ev {
                panic!("circuit breaker should prevent compaction once tripped");
            }
        }
        assert!(
            breaker_count >= 1,
            "must emit CircuitBreaker event when tripped"
        );
    }

    // ========================================================================
    // run_inner integration tests
    // ========================================================================
    // Kills: `delete ! in RunCore<'a>::execute_tool_calls` at line 306.
    // These tests call run_inner() directly to kill mutants deep inside the
    // main agent loop that cannot be reached by unit tests of sub-functions.
    // ========================================================================

    /// Helper: build a RunCore that can run through `run_inner`.
    /// Messages start with a single user turn; LLM and max_steps are caller-set.
    fn make_run_core_for_inner<'a>(
        messages: Vec<Message>,
        hooks: &'a crate::hooks::HookRegistry,
        provider: Arc<crate::llm::MockProvider>,
        max_steps: usize,
    ) -> RunCore<'a> {
        use std::sync::atomic::AtomicBool;
        RunCore {
            messages: Arc::new(messages),
            llm: provider,
            tools: Arc::new(crate::tools::ToolRegistry::default()),
            max_steps,
            max_transcript_chars: None,
            events: None,
            streaming: false,
            compactor: None,
            microcompactor: None,
            permission_hook: None,
            hooks,
            total_llm_latency_ms: 0,
            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
            shutdown_token: None,
            mailbox: None,
            stuck_window: 3,
            stuck_error_rate: 1.0,
            turn: 0,
            globs_skills: vec![],
            prompt_segments: None,
            static_breakdown: StaticBreakdownCache::default(),
            last_prompt_tokens: 0,
            consecutive_compact_failures: 0,
            wall_timeout_secs: 0,
            wall_start: None,
        }
    }

    /// Kills: `replace >= with <` at line 579.
    ///
    /// When `chars >= limit` after trimming, `run_inner` must return
    /// `TranscriptLimit`.  With the mutant (`< limit`), the condition is
    /// inverted and `TranscriptLimit` would fire when chars is BELOW the
    /// limit — the happy path.
    #[tokio::test]
    async fn run_inner_returns_transcript_limit_when_chars_at_or_above_limit() {
        use crate::agent::FinishReason;

        let hooks = crate::hooks::HookRegistry::new();
        // System message "hello" = 5 chars; set limit = 3 → 5 >= 3 → TranscriptLimit.
        let messages = vec![Message::system("hello".to_string())];
        let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
        core.max_transcript_chars = Some(3); // 5 chars > 3 → fires before LLM call

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
            "expected TranscriptLimit, got {:?}",
            outcome.finish_reason,
        );
    }

    /// Kills: `delete !` at line 679.
    ///
    /// When `reasoning_content` is non-empty, `run_inner` must emit a
    /// `AgentEvent::Reasoning` event.  With the mutant (delete `!`), the
    /// guard becomes `if reasoning.is_empty()`, so no event is emitted for
    /// non-empty reasoning.
    #[tokio::test]
    async fn run_inner_emits_reasoning_event_when_reasoning_nonempty() {
        use crate::event::AgentEvent;
        use crate::llm::Completion;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
            content: "done".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: Some("I thought about it".to_string()),
        }]));
        let messages = vec![Message::user("hello".to_string())];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
        core.events = Some(tx);

        let outcome = core.run_inner().await.expect("run_inner must not error");
        drop(outcome); // satisfy unused-var lint

        let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
        assert!(
            events.iter().any(|e| matches!(e, AgentEvent::Reasoning { .. })),
            "AgentEvent::Reasoning must be emitted when reasoning_content is non-empty; got: {events:?}",
        );
    }

    /// Kills: `replace match guard … with false` at line 700:32 AND
    ///        `replace != with ==` at line 700:49.
    ///
    /// When the LLM returns `finish_reason = Some("rate_limited")` with no
    /// tool calls, `run_inner` must classify it as `FinishReason::ProviderStop`.
    /// Both mutants break the match guard so "rate_limited" falls to the
    /// default `NoMoreToolCalls` arm instead.
    #[tokio::test]
    async fn run_inner_provider_stop_for_nonstandard_finish_reason() {
        use crate::agent::FinishReason;
        use crate::llm::Completion;

        let hooks = crate::hooks::HookRegistry::new();
        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
            content: "provider paused".to_string(),
            tool_calls: vec![],
            finish_reason: Some("rate_limited".to_string()),
            usage: None,
            reasoning_content: None,
        }]));
        let messages = vec![Message::user("hello".to_string())];
        let core = make_run_core_for_inner(messages, &hooks, provider, 1);

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(
                &outcome.finish_reason,
                FinishReason::ProviderStop(r) if r == "rate_limited"
            ),
            "expected ProviderStop(\"rate_limited\"), got {:?}",
            outcome.finish_reason,
        );
    }

    /// Complementary: `finish_reason = Some("stop")` → `NoMoreToolCalls`.
    /// Pins the pass-through branch of the same match guard.
    #[tokio::test]
    async fn run_inner_no_provider_stop_for_stop_finish_reason() {
        use crate::agent::FinishReason;
        use crate::llm::Completion;

        let hooks = crate::hooks::HookRegistry::new();
        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
            content: "all done".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: None,
            reasoning_content: None,
        }]));
        let messages = vec![Message::user("hello".to_string())];
        let core = make_run_core_for_inner(messages, &hooks, provider, 1);

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::NoMoreToolCalls),
            "expected NoMoreToolCalls, got {:?}",
            outcome.finish_reason,
        );
    }

    /// Goal 353 — Invariant #7 (finish reasons are data, not errors).
    ///
    /// HEADLINE regression: a mid-stream cancellation (the provider returns
    /// `Err(Error::Cancelled)` from the in-flight LLM call) must be translated
    /// into a `FinishReason::Cancelled` *outcome* — NOT bubbled as `Err` — so
    /// the caller (`runtime.rs`) persists the partial transcript, runs
    /// `emit_turn_messages` + compaction, and lets the `SessionEnd` hook
    /// suppression for cancellation fire. Before this fix the error rode `?`
    /// out of `run_inner` and the transcript save was skipped.
    #[tokio::test]
    async fn cancelled_mid_stream_persists_transcript_and_finish_reason() {
        use crate::agent::FinishReason;
        use crate::event::AgentEvent;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        // The error queue is consumed BEFORE scripted completions, so the
        // first LLM call returns `Err(Error::Cancelled)` — simulating the
        // provider aborting mid-stream when the shutdown token fires between
        // SSE chunks (see `anthropic.rs` / `openai.rs` stream loops).
        let provider = Arc::new(
            crate::llm::MockProvider::new(vec![]).with_errors(vec![crate::error::Error::Cancelled]),
        );
        let messages = vec![Message::user("hello".to_string())];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
        core.events = Some(tx);
        // Provide a token but leave it UN-cancelled: check_shutdown must NOT
        // fire — the Cancelled outcome has to come from the provider call.
        core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::Cancelled),
            "expected FinishReason::Cancelled, got {:?}",
            outcome.finish_reason,
        );
        // The cancellation happened DURING the first step's LLM call (the
        // loop's first iteration is step = 1), so the in-flight step is
        // counted. This is deliberately `step`, not `step - 1` like
        // check_shutdown — the call was in progress, not pre-call.
        assert_eq!(
            outcome.steps, 1,
            "mid-stream cancel counts the in-flight step (not step - 1)",
        );

        // The seeded transcript must survive into the outcome.
        assert_eq!(outcome.messages.len(), 1, "transcript must be preserved");
        assert_eq!(
            outcome.messages[0].role,
            crate::message::Role::User,
            "seeded user message must be in the outcome transcript",
        );

        let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
        assert!(
            events.iter().any(|e| matches!(
                e,
                AgentEvent::TurnFinished { reason, steps }
                    if reason.as_str() == "cancelled" && *steps == 1
            )),
            "TurnFinished(cancelled) must be emitted with the in-flight step; got: {events:?}",
        );
    }

    /// Goal 382 — HEADLINE: a stream-interrupted completion (the provider
    /// returns `Ok` with `finish_reason == Some("interrupted")` and partial
    /// content, as the openai.rs / anthropic.rs SSE parsers now do on a
    /// mid-stream network drop) must be persisted as a partial assistant
    /// message and finish the turn as `FinishReason::Cancelled` — NOT
    /// dropped. Before this goal the parser returned `Err` on the read
    /// error, which short-circuited `emit_turn_messages` and left the
    /// transcript ending with an unanswered user turn.
    #[tokio::test]
    async fn interrupted_completion_persists_partial_content_as_cancelled() {
        use crate::agent::FinishReason;
        use crate::llm::Completion;
        use crate::message::Role;

        let hooks = crate::hooks::HookRegistry::new();
        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
            content: "partial reply…".to_string(),
            tool_calls: vec![],
            finish_reason: Some("interrupted".to_string()),
            usage: None,
            reasoning_content: Some("half a thought".to_string()),
        }]));
        let messages = vec![Message::user("hello".to_string())];
        let core = make_run_core_for_inner(messages, &hooks, provider, 3);

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::Cancelled),
            "interrupted completion must finish as Cancelled, got {:?}",
            outcome.finish_reason,
        );

        // The partial reply must become the final_message so the caller's
        // persistence path treats this as a real (partial) turn.
        assert_eq!(
            outcome.final_message.as_deref(),
            Some("partial reply…"),
            "the partial reply must become the final_message",
        );

        // The partial assistant message (content + reasoning) must ride the
        // existing make_cancelled_outcome → new_messages path so
        // runtime.rs:emit_turn_messages writes it to disk.
        let assistant_msgs: Vec<&Message> = outcome
            .messages
            .iter()
            .filter(|m| m.role == Role::Assistant)
            .collect();
        assert_eq!(
            assistant_msgs.len(),
            1,
            "exactly one partial assistant message must be pushed",
        );
        assert_eq!(assistant_msgs[0].content, "partial reply…");
        assert_eq!(
            assistant_msgs[0].reasoning_content.as_deref(),
            Some("half a thought"),
            "reasoning must be attached to the partial assistant message",
        );
    }

    /// Goal 353 — regression guard for the PRE-EXISTING path.
    ///
    /// When the shutdown token is ALREADY cancelled at loop top (before any
    /// LLM call), `check_shutdown` handles it and reports `step - 1` (0 on the
    /// first iteration). This pins that path so reviewers can see both arms
    /// (between-steps vs mid-stream) are covered and deliberately distinct.
    #[tokio::test]
    async fn cancelled_between_steps_uses_check_shutdown_path() {
        use crate::agent::FinishReason;

        let hooks = crate::hooks::HookRegistry::new();
        // Empty provider: the LLM must NEVER be called — check_shutdown fires
        // at loop top before the call, so a call here would fail loudly
        // (MockProvider errors when its scripted queue is drained).
        let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
        let messages = vec![Message::user("hello".to_string())];
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
        core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());
        // Cancel BEFORE run_inner: fires check_shutdown on step 1's loop top.
        core.shutdown_token
            .as_ref()
            .expect("token just set")
            .cancel();

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::Cancelled),
            "expected FinishReason::Cancelled, got {:?}",
            outcome.finish_reason,
        );
        assert_eq!(
            outcome.steps, 0,
            "between-steps cancel happens before any LLM call → 0 steps",
        );
    }

    /// Goal 353 — negative guard.
    ///
    /// A NON-Cancelled error from the provider must still bubble out of
    /// `run_inner` as `Err`. The new match only swallows `Cancelled`; any
    /// other error keeps the original `?`-style propagation so genuine
    /// failures are not silently converted into finished turns.
    #[tokio::test]
    async fn non_cancelled_error_still_bubbles() {
        let hooks = crate::hooks::HookRegistry::new();
        // Error::Llm is not retried by the run path (retry handling lives in
        // the provider's own RetryPolicy, which MockProvider does not have),
        // so it reaches run_inner's match and must fall through to the Err arm.
        let provider = Arc::new(crate::llm::MockProvider::new(vec![]).with_errors(vec![
            crate::error::Error::Llm {
                provider: "mock".to_string(),
                message: "injected non-cancelled error".to_string(),
            },
        ]));
        let messages = vec![Message::user("hello".to_string())];
        let core = make_run_core_for_inner(messages, &hooks, provider, 1);

        let err = match core.run_inner().await {
            Ok(_) => panic!("run_inner must error for non-Cancelled provider errors"),
            Err(e) => e,
        };
        assert!(
            matches!(err, crate::error::Error::Llm { .. }),
            "expected Error::Llm, got {err:?}",
        );
    }

    /// Kills (all three together):
    ///   `replace && with ||` at line 820:43  — stuck guard uses `||`
    ///   `replace / with *`   at line 827:51  — rate = count * window (huge)
    ///   `replace += with *=` at line 833:59  — count never increments
    ///
    /// Run the agent with 3 consecutive single-tool-call steps that all fail
    /// with "ERROR: …" (unknown tool).  With `stuck_window = 3` and
    /// `stuck_error_rate = 1.0`, stuck detection must fire on step 3.
    ///
    /// Each mutant breaks a different aspect of stuck detection:
    /// - `|| ` guard: fires after the FIRST error (window not yet full)
    /// - `*` rate:    fires after the first error (rate = 1 * 3 = 3 ≥ 1.0)
    /// - `*=` count:  all error_count = 0 → rate = 0 → stuck NEVER fires
    #[tokio::test]
    async fn run_inner_stuck_detection_fires_after_window_of_errors() {
        use crate::agent::FinishReason;
        use crate::llm::{Completion, ToolCall};

        let hooks = crate::hooks::HookRegistry::new();
        // Three steps each requesting the same non-existent tool → all fail.
        let make_tc_completion = |id: &str| Completion {
            content: String::new(),
            tool_calls: vec![ToolCall {
                id: id.to_string(),
                name: "NonExistentTool".to_string(),
                arguments: serde_json::json!({}),
            }],
            finish_reason: None,
            usage: None,
            reasoning_content: None,
        };
        let provider = Arc::new(crate::llm::MockProvider::new(vec![
            make_tc_completion("tc1"),
            make_tc_completion("tc2"),
            make_tc_completion("tc3"),
        ]));
        let messages = vec![Message::user("keep trying".to_string())];
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
        core.stuck_window = 3;
        core.stuck_error_rate = 1.0;

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::Stuck { .. }),
            "expected Stuck after 3 consecutive errors, got {:?}",
            outcome.finish_reason,
        );
    }

    /// Kills: `replace && with ||` at line 820:43.
    ///
    /// The guard is `stuck_finish.is_none() && recent_errors.len() == stuck_window`.
    /// With `||`, it fires whenever `stuck_finish.is_none()` is true (always before
    /// stuck is set), so it triggers on every step — not just when the window fills.
    ///
    /// Setup: `stuck_window=3`, `stuck_error_rate=0.5`, `max_steps=2`.
    /// After 2 errors the window is NOT yet full (len=2 < window=3).
    ///   • Original (`&&`): condition false → no stuck → `BudgetExceeded`.
    ///   • Mutant (`||`): condition fires at step 2 (rate = 2/3 = 0.666 ≥ 0.5) → `Stuck`.
    #[tokio::test]
    async fn run_inner_stuck_fires_only_after_window_full() {
        use crate::agent::FinishReason;
        use crate::llm::{Completion, ToolCall};

        let hooks = crate::hooks::HookRegistry::new();
        let make_tc = |id: &str| Completion {
            content: String::new(),
            tool_calls: vec![ToolCall {
                id: id.to_string(),
                name: "NonExistentTool".to_string(),
                arguments: serde_json::json!({}),
            }],
            finish_reason: None,
            usage: None,
            reasoning_content: None,
        };
        // Two steps — both produce errors, but the window (size 3) never fills.
        let provider = Arc::new(crate::llm::MockProvider::new(vec![
            make_tc("tc1"),
            make_tc("tc2"),
        ]));
        let messages = vec![Message::user("go".to_string())];
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 2);
        core.stuck_window = 3;
        core.stuck_error_rate = 0.5; // 2/3 ≈ 0.666 ≥ 0.5 with mutant, but window not full

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
            "expected BudgetExceeded (window not yet full), got {:?}",
            outcome.finish_reason,
        );
    }

    /// Kills: `replace / with *` at line 827:51.
    ///
    /// `rate = error_count as f64 / stuck_window as f64` vs `* stuck_window`.
    /// With `window=3`, `error_count=1`, `stuck_error_rate=0.5`:
    ///   • Original: 1/3 ≈ 0.333 < 0.5 → no stuck → `BudgetExceeded`.
    ///   • Mutant:   1×3 = 3.0 ≥ 0.5   → stuck fires.
    ///
    /// We need exactly 1 error in a full window of 3: two successful SuccessTool
    /// calls followed by one NonExistentTool error fills the window with 1 error.
    #[tokio::test]
    async fn run_inner_stuck_rate_uses_division_not_multiplication() {
        use crate::agent::FinishReason;
        use crate::llm::ToolSpec;
        use crate::llm::{Completion, ToolCall};
        use crate::tools::{Tool, ToolRegistry};
        use async_trait::async_trait;

        struct SuccessTool;

        #[async_trait]
        impl Tool for SuccessTool {
            fn spec(&self) -> ToolSpec {
                ToolSpec {
                    name: "SuccessTool".to_string(),
                    description: "Returns ok".to_string(),
                    parameters: serde_json::json!({ "type": "object", "properties": {} }),
                }
            }
            async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
                Ok("ok".to_string())
            }
        }

        let hooks = crate::hooks::HookRegistry::new();
        let registry = ToolRegistry::default().register(Arc::new(SuccessTool));

        let make_ok = |id: &str| Completion {
            content: String::new(),
            tool_calls: vec![ToolCall {
                id: id.to_string(),
                name: "SuccessTool".to_string(),
                arguments: serde_json::json!({}),
            }],
            finish_reason: None,
            usage: None,
            reasoning_content: None,
        };
        let make_err = |id: &str| Completion {
            content: String::new(),
            tool_calls: vec![ToolCall {
                id: id.to_string(),
                name: "NonExistentTool".to_string(),
                arguments: serde_json::json!({}),
            }],
            finish_reason: None,
            usage: None,
            reasoning_content: None,
        };
        // success, success, error → window=3 full with 1 error
        let provider = Arc::new(crate::llm::MockProvider::new(vec![
            make_ok("tc1"),
            make_ok("tc2"),
            make_err("tc3"),
        ]));
        let messages = vec![Message::user("test".to_string())];
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
        core.tools = Arc::new(registry);
        core.stuck_window = 3;
        core.stuck_error_rate = 0.5; // 1/3 ≈ 0.333 < 0.5 → no stuck (orig); 1×3=3.0 ≥ 0.5 (mutant)

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
            "expected BudgetExceeded (rate below threshold), got {:?}",
            outcome.finish_reason,
        );
    }

    /// Kills: `replace || with &&` at line 750:57 AND
    ///        `replace == with !=` at line 750:69.
    ///
    /// `DENIAL_LIMIT_SENTINEL = "ERROR_DENIAL_LIMIT:"` does NOT start with "ERROR: "
    /// (note the space), so `is_error` is true only via the `|| o.result == DENIAL_LIMIT_SENTINEL`
    /// branch.
    ///
    /// - Mutant 750:57 (`&&` instead of `||`): `false && true = false` → wrong
    /// - Mutant 750:69 (`!=` instead of `==`): `false || false = false` → wrong
    ///
    /// Both mutants emit `AgentEvent::ToolResult { is_error: false }` for the sentinel,
    /// but the correct code must emit `is_error: true`.
    #[tokio::test]
    async fn run_inner_denial_sentinel_emits_is_error_true() {
        use crate::event::AgentEvent;
        use crate::llm::{Completion, ToolCall, ToolSpec};
        use crate::tools::{Tool, ToolRegistry};
        use async_trait::async_trait;
        use tokio::sync::mpsc;

        struct DenialTool;

        #[async_trait]
        impl Tool for DenialTool {
            fn spec(&self) -> ToolSpec {
                ToolSpec {
                    name: "DenialTool".to_string(),
                    description: "Returns PermissionDeniedLimit".to_string(),
                    parameters: serde_json::json!({ "type": "object", "properties": {} }),
                }
            }
            async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
                Err(crate::error::Error::PermissionDeniedLimit {
                    name: "DenialTool".to_string(),
                })
            }
        }

        let hooks = crate::hooks::HookRegistry::new();
        let registry = ToolRegistry::default().register(Arc::new(DenialTool));
        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
            content: String::new(),
            tool_calls: vec![ToolCall {
                id: "tc1".to_string(),
                name: "DenialTool".to_string(),
                arguments: serde_json::json!({}),
            }],
            finish_reason: None,
            usage: None,
            reasoning_content: None,
        }]));

        let messages = vec![Message::user("trigger denial".to_string())];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
        core.tools = Arc::new(registry);
        core.events = Some(tx);

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(
                outcome.finish_reason,
                crate::agent::FinishReason::PermissionDenialLimit
            ),
            "expected PermissionDenialLimit, got {:?}",
            outcome.finish_reason,
        );

        let mut events = vec![];
        while let Ok(e) = rx.try_recv() {
            events.push(e);
        }
        let denial_is_error = events.iter().find_map(|e| {
            if let AgentEvent::ToolResult { name, is_error, .. } = e {
                if name == "DenialTool" {
                    return Some(*is_error);
                }
            }
            None
        });
        assert_eq!(
            denial_is_error,
            Some(true),
            "ToolResult for DENIAL_LIMIT_SENTINEL must have is_error = true; events: {events:?}"
        );
    }

    // ========================================================================
    // Goal-328: ContextBreakdown event emission
    // ========================================================================

    /// `compute_breakdown` must populate `overhead = max(0, provider_total -
    /// local_sum)`. With `prompt_tokens = 1000` and a local sum of 700,
    /// the breakdown's overhead must be 300. The other buckets must match
    /// `static_breakdown` (cached at construction).
    #[test]
    fn compute_breakdown_overhead_is_provider_total_minus_local_sum() {
        use crate::llm::ContextBreakdown;

        let hooks = crate::hooks::HookRegistry::new();
        // Seed a transcript long enough to make the conversation bucket non-zero.
        let messages = vec![
            Message::system("s".to_string()),
            Message::user("hello world this is a turn".to_string()),
            Message::assistant("ok".to_string()),
        ];
        let mut core = make_test_core(messages, &hooks);
        // Hand-build a static breakdown whose sum we can predict.
        core.static_breakdown = StaticBreakdownCache {
            system_prompt: 100,
            rules: 50,
            skills: 25,
            subagents: 10,
            tools: 15,
            mcp_dynamic: 0,
        };
        let breakdown = core.compute_breakdown(1000);

        // Provider total - local_sum = 1000 - (100 + 50 + 25 + 10 + 15 + 0 + conversation).
        let local = breakdown.local_sum();
        let expected_overhead = 1000u32.saturating_sub(local);
        assert_eq!(
            breakdown.overhead, expected_overhead,
            "overhead must be provider_total - local_sum"
        );
        // Conversation must be > 0 (we seeded a non-trivial transcript).
        assert!(
            breakdown.conversation > 0,
            "conversation bucket must be non-zero for a seeded transcript"
        );
        // Static buckets must mirror the cache verbatim.
        assert_eq!(breakdown.system_prompt, 100);
        assert_eq!(breakdown.rules, 50);
        assert_eq!(breakdown.skills, 25);
        assert_eq!(breakdown.subagents, 10);
        assert_eq!(breakdown.tools, 15);
        assert_eq!(breakdown.mcp_dynamic, 0);
        // Sanity: the public type's helpers agree.
        assert_eq!(breakdown.local_sum(), local);
        assert_eq!(breakdown.total(), local + expected_overhead);
        // Silence unused-variable warning for ContextBreakdown import.
        let _ = ContextBreakdown::default();
    }

    /// When the local sum exceeds the provider's reported total (which can
    /// happen with chars/4 over-estimation on dense CJK content), the
    /// overhead bucket must saturate to 0 — not wrap to a huge u32.
    #[test]
    fn compute_breakdown_overhead_saturates_at_zero_when_local_exceeds_provider() {
        let hooks = crate::hooks::HookRegistry::new();
        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
        core.static_breakdown = StaticBreakdownCache {
            system_prompt: 5000,
            rules: 1000,
            skills: 0,
            subagents: 0,
            tools: 0,
            mcp_dynamic: 0,
        };
        let breakdown = core.compute_breakdown(100); // local sum ≫ 100
        assert_eq!(
            breakdown.overhead, 0,
            "overhead must saturate to 0 when local sum > provider total; got {}",
            breakdown.overhead
        );
    }

    /// `emit_breakdown` consumes a `TokenUsage` and emits
    /// `AgentEvent::ContextBreakdown`. We assert the emitted event's
    /// bucket totals match the spec (overhead uses `max(input_tokens,
    /// cache_hit + cache_miss)` so Anthropic + OpenAI reporting
    /// differences are handled).
    #[tokio::test]
    async fn dispatch_llm_step_emits_context_breakdown_after_usage() {
        use crate::event::AgentEvent;
        use crate::llm::TokenUsage;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let mut core = make_test_core(vec![Message::user("hello world".to_string())], &hooks);
        // Seed static buckets so the breakdown has deterministic non-zero values.
        core.static_breakdown = StaticBreakdownCache {
            system_prompt: 100,
            rules: 50,
            skills: 25,
            subagents: 10,
            tools: 15,
            mcp_dynamic: 0,
        };
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        core.events = Some(tx);

        // The `Usage` reading: provider reports 1000 prompt tokens with
        // no cache split (OpenAI shape). Anthropic-style reporting would
        // put the cache split separately; both flows feed the breakdown
        // the same `max(input_tokens, cache_sum)`.
        let usage = TokenUsage {
            prompt_tokens: 1000,
            completion_tokens: 50,
            total_tokens: 1050,
            cache_hit_tokens: 0,
            cache_miss_tokens: 0,
            reasoning_tokens: 0,
        };
        core.emit_breakdown(7, &usage);

        // Drain the channel and find the ContextBreakdown event.
        let event = rx
            .try_recv()
            .expect("ContextBreakdown event must be emitted");
        match event {
            AgentEvent::ContextBreakdown { breakdown, step } => {
                assert_eq!(step, 7, "step must be forwarded verbatim");
                let local = breakdown.local_sum();
                let expected_overhead = 1000u32.saturating_sub(local);
                assert_eq!(breakdown.overhead, expected_overhead);
                assert!(
                    breakdown.total() >= 1000,
                    "total must be >= provider_total; got {}",
                    breakdown.total()
                );
            }
            other => panic!("expected ContextBreakdown event, got {other:?}"),
        }
    }

    /// `run_inner` emits `AgentEvent::ContextBreakdown` once per
    /// LLM-calling step (i.e. exactly once on the single-step happy
    /// path). The breakdown must come AFTER `Usage` so consumers
    /// observe the provider truth first.
    #[tokio::test]
    async fn run_inner_emits_context_breakdown_once_per_llm_step() {
        use crate::agent::FinishReason;
        use crate::event::AgentEvent;
        use crate::llm::{Completion, TokenUsage};
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
            content: "done".to_string(),
            tool_calls: vec![],
            finish_reason: Some("stop".to_string()),
            usage: Some(TokenUsage {
                prompt_tokens: 1000,
                completion_tokens: 50,
                total_tokens: 1050,
                cache_hit_tokens: 0,
                cache_miss_tokens: 0,
                reasoning_tokens: 0,
            }),
            reasoning_content: None,
        }]));
        let messages = vec![Message::user("hi".to_string())];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
        core.events = Some(tx);

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);

        // Drain events. Find the index of Usage and the index of
        // ContextBreakdown; the latter must come strictly after.
        let mut events = Vec::new();
        while let Ok(e) = rx.try_recv() {
            events.push(e);
        }
        let usage_idx = events
            .iter()
            .position(|e| matches!(e, AgentEvent::Usage { .. }))
            .expect("a Usage event must have been emitted");
        let breakdown_idx = events
            .iter()
            .position(|e| matches!(e, AgentEvent::ContextBreakdown { .. }))
            .expect("a ContextBreakdown event must have been emitted");
        assert!(
            breakdown_idx > usage_idx,
            "ContextBreakdown (idx={breakdown_idx}) must come AFTER Usage (idx={usage_idx})"
        );
    }

    /// `run_inner` does NOT emit `ContextBreakdown` on a step that
    /// never calls the LLM (e.g. an immediate `TranscriptLimit` finish).
    #[tokio::test]
    async fn run_inner_skips_context_breakdown_on_no_llm_step() {
        use crate::agent::FinishReason;
        use crate::event::AgentEvent;
        use tokio::sync::mpsc;

        let hooks = crate::hooks::HookRegistry::new();
        let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
        let messages = vec![Message::system("hello".to_string())];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
        core.events = Some(tx);
        // 5 chars > limit 3 → enforce_transcript_budget fires BEFORE the
        // first LLM call, so there should be no Usage / ContextBreakdown.
        core.max_transcript_chars = Some(3);

        let outcome = core.run_inner().await.expect("run_inner must not error");
        assert!(
            matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
            "expected TranscriptLimit finish; got {:?}",
            outcome.finish_reason
        );

        let mut events = Vec::new();
        while let Ok(e) = rx.try_recv() {
            events.push(e);
        }
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, AgentEvent::ContextBreakdown { .. })),
            "no ContextBreakdown must be emitted on a no-LLM step; got: {events:?}"
        );
        assert!(
            !events.iter().any(|e| matches!(e, AgentEvent::Usage { .. })),
            "no Usage must be emitted on a no-LLM step; got: {events:?}"
        );
    }

    /// Static breakdown buckets must be cached once at `RunCore`
    /// construction and reused every step — the conversation bucket
    /// alone grows between steps. Verified by running two steps and
    /// comparing the breakdowns.
    ///
    /// Step 1: LLM returns a tool call (forces a second step).
    /// Step 2: LLM returns a final reply (loop exits).
    #[tokio::test]
    async fn static_buckets_dont_change_across_steps_conversation_grows() {
        use crate::event::AgentEvent;
        use crate::llm::{Completion, TokenUsage, ToolCall, ToolSpec};
        use crate::tools::{Tool, ToolRegistry};
        use async_trait::async_trait;
        use serde_json::{json, Value};
        use tokio::sync::mpsc;

        /// Minimal tool that always succeeds so step 1's tool call is
        /// dispatched cleanly and the loop proceeds to step 2.
        struct Adder;
        #[async_trait]
        impl Tool for Adder {
            fn spec(&self) -> ToolSpec {
                ToolSpec {
                    name: "add".to_string(),
                    description: "add two numbers".to_string(),
                    parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
                }
            }
            async fn execute(&self, _args: Value) -> crate::error::Result<String> {
                Ok("7".to_string())
            }
        }

        let hooks = crate::hooks::HookRegistry::new();
        let registry = ToolRegistry::default().register(Arc::new(Adder));
        let provider = Arc::new(crate::llm::MockProvider::new(vec![
            // Step 1: tool call → forces step 2.
            Completion {
                content: "calculating…".to_string(),
                tool_calls: vec![ToolCall {
                    id: "tc1".to_string(),
                    name: "add".to_string(),
                    arguments: json!({"a": 3, "b": 4}),
                }],
                finish_reason: Some("tool_calls".to_string()),
                usage: Some(TokenUsage {
                    prompt_tokens: 1000,
                    completion_tokens: 30,
                    total_tokens: 1030,
                    cache_hit_tokens: 0,
                    cache_miss_tokens: 0,
                    reasoning_tokens: 0,
                }),
                reasoning_content: None,
            },
            // Step 2: final reply → loop exits.
            Completion {
                content: "the answer is 7".to_string(),
                tool_calls: vec![],
                finish_reason: Some("stop".to_string()),
                usage: Some(TokenUsage {
                    prompt_tokens: 1200, // provider thinks transcript grew
                    completion_tokens: 50,
                    total_tokens: 1250,
                    cache_hit_tokens: 0,
                    cache_miss_tokens: 0,
                    reasoning_tokens: 0,
                }),
                reasoning_content: None,
            },
        ]));
        let messages = vec![Message::user("hi".to_string())];
        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
        let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
        core.tools = Arc::new(registry);
        core.events = Some(tx);
        // Seed static buckets so we can verify they're identical across
        // both steps.
        core.static_breakdown = StaticBreakdownCache {
            system_prompt: 100,
            rules: 50,
            skills: 25,
            subagents: 10,
            tools: 15,
            mcp_dynamic: 0,
        };
        core.prompt_segments = Some(crate::system_prompt::PromptSegments {
            rules: "RR".into(),
            system_prompt: "SS".into(),
            skills: String::new(),
            subagents: String::new(),
        });

        let outcome = core.run_inner().await.expect("run_inner must not error");
        drop(outcome); // satisfy unused-var lint

        // Collect both ContextBreakdown events.
        let mut breakdowns = Vec::new();
        while let Ok(e) = rx.try_recv() {
            if let AgentEvent::ContextBreakdown { breakdown, .. } = e {
                breakdowns.push(breakdown);
            }
        }
        assert_eq!(
            breakdowns.len(),
            2,
            "two ContextBreakdown events must have been emitted; got {breakdowns:?}"
        );

        // Static buckets must be identical across both steps.
        assert_eq!(breakdowns[0].system_prompt, breakdowns[1].system_prompt);
        assert_eq!(breakdowns[0].rules, breakdowns[1].rules);
        assert_eq!(breakdowns[0].skills, breakdowns[1].skills);
        assert_eq!(breakdowns[0].subagents, breakdowns[1].subagents);
        assert_eq!(breakdowns[0].tools, breakdowns[1].tools);
        assert_eq!(breakdowns[0].mcp_dynamic, breakdowns[1].mcp_dynamic);
        assert_eq!(
            breakdowns[0].system_prompt, 100,
            "static_breakdown cache must be honoured"
        );

        // Conversation bucket must grow between step 1 and step 2
        // (the tool result + assistant reply add to the transcript).
        assert!(
            breakdowns[1].conversation > breakdowns[0].conversation,
            "conversation must grow between steps: step1={} step2={}",
            breakdowns[0].conversation,
            breakdowns[1].conversation
        );

        // Overhead must be positive (provider total > local sum): the
        // provider reports 1000 / 1200 prompt tokens, which includes
        // wrapping the local estimate doesn't capture.
        assert!(breakdowns[0].overhead > 0);
        assert!(breakdowns[1].overhead > 0);
    }

    /// `StaticBreakdownCache::build` must partition specs into eager
    /// vs deferred via `ToolRegistry::is_deferred_spec`. We can't easily
    /// register a deferred spec on a `default()` registry, so the
    /// zero-spec case must produce zero tokens and the cache must not
    /// crash.
    #[test]
    fn static_breakdown_cache_build_zero_specs_yields_zero_tokens() {
        let segments = crate::system_prompt::PromptSegments::default();
        let registry = crate::tools::ToolRegistry::default();
        let cache = StaticBreakdownCache::build(&segments, &[], &registry);
        assert_eq!(cache.system_prompt, 0);
        assert_eq!(cache.rules, 0);
        assert_eq!(cache.skills, 0);
        assert_eq!(cache.subagents, 0);
        assert_eq!(cache.tools, 0);
        assert_eq!(cache.mcp_dynamic, 0);
    }

    /// `StaticBreakdownCache::build` must tokenise each segment's
    /// length using chars/4 ceil, identical to `llm::estimate_tokens`.
    #[test]
    fn static_breakdown_cache_build_tokenises_segments() {
        // 9-char segment → ceil(9/4) = 3 tokens. (9/4 = 2.25, ceil = 3.)
        let segments = crate::system_prompt::PromptSegments {
            rules: "012345678".into(),          // 9 chars
            system_prompt: "1234567890".into(), // 10 chars → 3 tokens (10/4=2.5, ceil=3)
            skills: "abcde".into(),             // 5 chars → 2 tokens (5/4=1.25, ceil=2)
            subagents: "abcdefgh".into(),       // 8 chars → 2 tokens (8/4=2.0, ceil=2)
        };
        let registry = crate::tools::ToolRegistry::default();
        let cache = StaticBreakdownCache::build(&segments, &[], &registry);
        assert_eq!(cache.rules, 3, "9 chars / 4 = 2.25 → ceil = 3");
        assert_eq!(cache.system_prompt, 3, "10 chars / 4 = 2.5 → ceil = 3");
        assert_eq!(cache.skills, 2, "5 chars / 4 = 1.25 → ceil = 2");
        assert_eq!(cache.subagents, 2, "8 chars / 4 = 2.0 → ceil = 2");
    }

    #[test]
    fn estimate_tokens_by_bytes_rounds_up_by_four_bytes() {
        // Kills `/ -> *` (63:32) and `> -> >=` / `<` / `==` (64:29) in
        // estimate_tokens_by_bytes: 0 bytes → 0 tokens, any non-zero bytes
        // → ceil(bytes / 4), and a tiny positive byte count must still be 1.
        assert_eq!(estimate_tokens_by_bytes(0), 0);
        assert_eq!(estimate_tokens_by_bytes(1), 1);
        assert_eq!(estimate_tokens_by_bytes(4), 1);
        assert_eq!(estimate_tokens_by_bytes(5), 2);
        assert_eq!(estimate_tokens_by_bytes(8), 2);
    }

    #[test]
    fn enforce_transcript_budget_fires_when_chars_exactly_equal_limit() {
        // Kills `< -> <=` (672:18) in enforce_transcript_budget: when the
        // transcript is EXACTLY at the limit the budget must fire (Some);
        // the `<=` mutant would treat it as under-budget (None).
        use crate::agent::FinishReason;
        use crate::llm::TokenUsage;
        let hooks = crate::hooks::HookRegistry::new();
        // "hello" = exactly 5 chars, limit = 5.
        let mut core = make_test_core(vec![Message::user("hello".to_string())], &hooks);
        core.max_transcript_chars = Some(5);
        let usage = TokenUsage::default();
        let result = core.enforce_transcript_budget(0, &usage);
        assert!(
            result.is_some(),
            "chars (5) == limit (5) must return Some finish reason"
        );
        let (finish, _step) = result.unwrap();
        assert!(
            matches!(finish, FinishReason::TranscriptLimit { .. }),
            "finish reason must be TranscriptLimit; got {finish:?}"
        );
    }

    #[test]
    fn check_wall_deadline_disabled_when_timeout_zero() {
        // Kills `== -> !=` (735:35) in check_wall_deadline: a timeout of 0
        // must disable the deadline entirely (None); the `!=` mutant would
        // fall through to the elapsed check and fire.
        use crate::llm::TokenUsage;
        let hooks = crate::hooks::HookRegistry::new();
        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
        core.wall_timeout_secs = 0;
        core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
        let usage = TokenUsage::default();
        assert!(
            core.check_wall_deadline(0, &usage).is_none(),
            "timeout 0 must disable the wall-clock deadline"
        );
    }

    #[test]
    fn check_wall_deadline_fires_after_timeout_elapsed() {
        // Kills `-> None` (735:9) and `< -> >` (739:30): with elapsed (5s)
        // strictly greater than the timeout (3s) the deadline must fire.
        use crate::agent::FinishReason;
        use crate::llm::TokenUsage;
        let hooks = crate::hooks::HookRegistry::new();
        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
        core.wall_timeout_secs = 3;
        core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
        let usage = TokenUsage::default();
        let result = core.check_wall_deadline(0, &usage);
        assert!(
            result.is_some(),
            "elapsed 5s > timeout 3s must fire the deadline"
        );
        let (finish, _step) = result.unwrap();
        assert!(
            matches!(finish, FinishReason::WallClockExceeded { .. }),
            "finish reason must be WallClockExceeded; got {finish:?}"
        );
    }

    #[test]
    fn check_wall_deadline_fires_when_elapsed_equals_timeout() {
        // Kills `< -> <=` and `< -> ==` (739:30): when elapsed seconds EXACTLY
        // equal the timeout, `elapsed < timeout` is false → the deadline must
        // fire; `<=`/`==` would return None. `wall_start` is set 3s in the
        // past and the deadline checked immediately, so `elapsed().as_secs()`
        // is exactly 3 (a full second boundary is guaranteed until >1s passes
        // between construction and the check).
        use crate::llm::TokenUsage;
        let hooks = crate::hooks::HookRegistry::new();
        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
        core.wall_timeout_secs = 3;
        core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
        let usage = TokenUsage::default();
        let result = core.check_wall_deadline(0, &usage);
        assert!(
            result.is_some(),
            "elapsed == timeout must fire the deadline"
        );
    }
}