pi-mega-compact 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +19 -0
  2. package/dist/extensions/dashboard-server/api-contracts/memory.js +8 -0
  3. package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +1 -0
  4. package/dist/extensions/dashboard-server/api-contracts/setup.js +10 -0
  5. package/dist/extensions/dashboard-server/index-reader.js +6 -2
  6. package/dist/extensions/dashboard-server/routes-cache.js +115 -55
  7. package/dist/extensions/dashboard-server/routes-memory.js +51 -0
  8. package/dist/extensions/dashboard-server/routes-repo.js +2 -2
  9. package/dist/extensions/dashboard-server/routes-setup.js +162 -0
  10. package/dist/extensions/dashboard-server/routes.js +2 -0
  11. package/dist/extensions/dashboard-server/server.js +7 -1
  12. package/dist/extensions/mega-commands.js +189 -1
  13. package/dist/extensions/mega-config.js +3 -0
  14. package/dist/extensions/mega-events/agent-handlers.js +41 -6
  15. package/dist/extensions/mega-events/compact-handlers.js +1 -0
  16. package/dist/extensions/mega-events/context-handler.js +37 -92
  17. package/dist/extensions/mega-events/context-hwm.js +51 -0
  18. package/dist/extensions/mega-events/error-classifier.js +118 -128
  19. package/dist/extensions/mega-events/mirror-append.js +107 -0
  20. package/dist/extensions/mega-events/outage-advisor.js +59 -0
  21. package/dist/extensions/mega-events/perf-handler.js +51 -1
  22. package/dist/extensions/mega-events/recall-tail.js +59 -0
  23. package/dist/extensions/mega-events/session-handlers.js +15 -8
  24. package/dist/extensions/mega-runtime/reset-runtime.js +6 -0
  25. package/dist/extensions/mega-runtime/runtime.js +7 -0
  26. package/dist/src/embedder.js +41 -2
  27. package/dist/src/memoryStats.js +75 -0
  28. package/dist/src/prefix-break.js +43 -0
  29. package/dist/src/pricing.js +1 -1
  30. package/dist/src/store/sqlite/perf-samples.js +144 -16
  31. package/dist/src/store/turns/memory-store.js +8 -0
  32. package/dist/src/store/turns/sqlite-store.js +14 -3
  33. package/dist/src/store/turns/types.js +18 -1
  34. package/dist/src/store/vectorIndex.js +26 -5
  35. package/extensions/dashboard-client/dist/assets/{AchievementTiles-vEo2MtE2.js → AchievementTiles-BoL7qBOJ.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{AchievementTiles-vEo2MtE2.js.map → AchievementTiles-BoL7qBOJ.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/AchievementsTab-BFDE3HYc.js +2 -0
  38. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DjxfYrDc.js.map → AchievementsTab-BFDE3HYc.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/CacheTab-eKVc-NV1.js +2 -0
  40. package/extensions/dashboard-client/dist/assets/CacheTab-eKVc-NV1.js.map +1 -0
  41. package/extensions/dashboard-client/dist/assets/{ConfigTab-Cm6ymbmx.js → ConfigTab-DDFE-ckA.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{ConfigTab-Cm6ymbmx.js.map → ConfigTab-DDFE-ckA.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{EventsTab-DXWl_YzI.js → EventsTab-B6UxZ7PM.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{EventsTab-DXWl_YzI.js.map → EventsTab-B6UxZ7PM.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{GameTab-BLDudcO5.js → GameTab-DehUX-KE.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{GameTab-BLDudcO5.js.map → GameTab-DehUX-KE.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DLkKCztn.js → MaintenanceTab-0GWRt4wn.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DLkKCztn.js.map → MaintenanceTab-0GWRt4wn.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cq4u-rZF.js → MetricsTab-C3rwMVsN.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cq4u-rZF.js.map → MetricsTab-C3rwMVsN.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/OverviewTab-39O9_x5j.js +2 -0
  52. package/extensions/dashboard-client/dist/assets/{OverviewTab-D4kGvsiQ.js.map → OverviewTab-39O9_x5j.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/ReposTab-Cfj6Q5sH.js +2 -0
  54. package/extensions/dashboard-client/dist/assets/ReposTab-Cfj6Q5sH.js.map +1 -0
  55. package/extensions/dashboard-client/dist/assets/{SessionsTab-DZC2-GCn.js → SessionsTab-DXDTCi3r.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{SessionsTab-DZC2-GCn.js.map → SessionsTab-DXDTCi3r.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/SetupTab-mMlfunJa.js +2 -0
  58. package/extensions/dashboard-client/dist/assets/SetupTab-mMlfunJa.js.map +1 -0
  59. package/extensions/dashboard-client/dist/assets/TimeSavedCard-Cw1dJ4e9.js +2 -0
  60. package/extensions/dashboard-client/dist/assets/TimeSavedCard-Cw1dJ4e9.js.map +1 -0
  61. package/extensions/dashboard-client/dist/assets/{TopicsTab-01G71hv1.js → TopicsTab-BAw972BV.js} +2 -2
  62. package/extensions/dashboard-client/dist/assets/{TopicsTab-01G71hv1.js.map → TopicsTab-BAw972BV.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{TurnsTab-DLRg0LZT.js → TurnsTab-Cf5mGIdl.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{TurnsTab-DLRg0LZT.js.map → TurnsTab-Cf5mGIdl.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/format-COjDIVGj.js +2 -0
  66. package/extensions/dashboard-client/dist/assets/format-COjDIVGj.js.map +1 -0
  67. package/extensions/dashboard-client/dist/assets/{index-BX0bcgLu.js → index-4gQ7auad.js} +10 -10
  68. package/extensions/dashboard-client/dist/assets/index-4gQ7auad.js.map +1 -0
  69. package/extensions/dashboard-client/dist/assets/pricing-CvE4NllQ.js +2 -0
  70. package/extensions/dashboard-client/dist/assets/pricing-CvE4NllQ.js.map +1 -0
  71. package/extensions/dashboard-client/dist/assets/{useSSE-BBYt5UPB.js → useSSE-DNgMuyP6.js} +2 -2
  72. package/extensions/dashboard-client/dist/assets/{useSSE-BBYt5UPB.js.map → useSSE-DNgMuyP6.js.map} +1 -1
  73. package/extensions/dashboard-client/dist/index.html +1 -1
  74. package/extensions/dashboard-client/src/App.tsx +4 -0
  75. package/extensions/dashboard-client/src/api/client.ts +14 -0
  76. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +21 -12
  77. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +56 -0
  78. package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +38 -57
  79. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +1 -0
  80. package/extensions/dashboard-client/src/tabs/SetupTab.tsx +355 -0
  81. package/extensions/dashboard-client/tsconfig.json +3 -2
  82. package/extensions/dashboard-client/vite.config.ts +2 -0
  83. package/extensions/dashboard-server/api-contracts/endpoints.ts +36 -3
  84. package/extensions/dashboard-server/api-contracts/index.ts +7 -0
  85. package/extensions/dashboard-server/api-contracts/memory.ts +27 -0
  86. package/extensions/dashboard-server/api-contracts/multi-repo.ts +231 -231
  87. package/extensions/dashboard-server/api-contracts/provider-cache.ts +37 -0
  88. package/extensions/dashboard-server/api-contracts/setup.ts +58 -0
  89. package/extensions/dashboard-server/index-reader.ts +138 -121
  90. package/extensions/dashboard-server/routes-cache.ts +161 -85
  91. package/extensions/dashboard-server/routes-memory.ts +63 -0
  92. package/extensions/dashboard-server/routes-repo.ts +36 -36
  93. package/extensions/dashboard-server/routes-setup.ts +181 -0
  94. package/extensions/dashboard-server/routes.ts +2 -0
  95. package/extensions/dashboard-server/server.ts +6 -0
  96. package/extensions/mega-commands.ts +211 -1
  97. package/extensions/mega-config.ts +18 -0
  98. package/extensions/mega-events/agent-handlers.ts +44 -5
  99. package/extensions/mega-events/compact-handlers.ts +1 -0
  100. package/extensions/mega-events/context-handler.ts +45 -110
  101. package/extensions/mega-events/context-hwm.ts +85 -0
  102. package/extensions/mega-events/error-classifier.ts +156 -124
  103. package/extensions/mega-events/mirror-append.ts +155 -0
  104. package/extensions/mega-events/outage-advisor.ts +79 -0
  105. package/extensions/mega-events/perf-handler.ts +79 -5
  106. package/extensions/mega-events/recall-tail.ts +88 -0
  107. package/extensions/mega-events/session-handlers.ts +14 -7
  108. package/extensions/mega-runtime/helpers.ts +14 -0
  109. package/extensions/mega-runtime/reset-runtime.ts +6 -0
  110. package/extensions/mega-runtime/runtime.ts +7 -0
  111. package/package.json +5 -2
  112. package/src/embedder.ts +42 -2
  113. package/src/memoryStats.ts +107 -0
  114. package/src/prefix-break.ts +83 -0
  115. package/src/pricing.ts +2 -2
  116. package/src/store/sqlite/perf-samples.ts +215 -22
  117. package/src/store/turns/memory-store.ts +10 -1
  118. package/src/store/turns/sqlite-store.ts +27 -15
  119. package/src/store/turns/types.ts +21 -0
  120. package/src/store/vectorIndex.ts +33 -4
  121. package/dist/extensions/conflict-scan.test.js +0 -115
  122. package/dist/extensions/dashboard-server/api-contracts.test.js +0 -884
  123. package/dist/extensions/dashboard-server/html.test.js +0 -52
  124. package/dist/extensions/dashboard-server/perf-server.test.js +0 -80
  125. package/dist/extensions/dashboard-server/routes-cache.test.js +0 -248
  126. package/dist/extensions/dashboard-server/server.test.js +0 -120
  127. package/dist/extensions/dashboard-server-s32.test.js +0 -181
  128. package/dist/extensions/dashboard-server.test.js +0 -339
  129. package/dist/extensions/mega-cache-replay.test.js +0 -400
  130. package/dist/extensions/mega-compact-s38.test.js +0 -820
  131. package/dist/extensions/mega-compact.test.js +0 -1112
  132. package/dist/extensions/mega-events.test.js +0 -47
  133. package/dist/extensions/mega-game-cmds.test.js +0 -163
  134. package/dist/extensions/mega-runtime/snapshot.test.js +0 -127
  135. package/dist/extensions/mega-runtime/state.test.js +0 -266
  136. package/dist/extensions/mega-runtime/widget.test.js +0 -343
  137. package/dist/extensions/mega-shutdown-widget.test.js +0 -121
  138. package/dist/extensions/mega-teamrun.test.js +0 -156
  139. package/dist/extensions/mega-trim.test.js +0 -58
  140. package/dist/extensions/mega-turn-store.test.js +0 -68
  141. package/dist/src/boundary.test.js +0 -170
  142. package/dist/src/compact.test.js +0 -78
  143. package/dist/src/config/themes.test.js +0 -94
  144. package/dist/src/dedup/dedup.test.js +0 -41
  145. package/dist/src/dedup/l1.test.js +0 -50
  146. package/dist/src/dedup/raptor/buildHistory.test.js +0 -292
  147. package/dist/src/dedup/raptor/multilevel-serve.test.js +0 -229
  148. package/dist/src/dedup/raptor/multilevel.test.js +0 -238
  149. package/dist/src/dedup/raptor/promote.test.js +0 -69
  150. package/dist/src/dedup/raptor/raptor.test.js +0 -248
  151. package/dist/src/dedup/raptor/retrieval.test.js +0 -95
  152. package/dist/src/dedup/raptor/serve-gate.test.js +0 -298
  153. package/dist/src/dedup/sprint12.test.js +0 -219
  154. package/dist/src/dedup-engine.test.js +0 -472
  155. package/dist/src/driftDetection.test.js +0 -87
  156. package/dist/src/e2e.test.js +0 -698
  157. package/dist/src/engine.test.js +0 -111
  158. package/dist/src/extractive.test.js +0 -147
  159. package/dist/src/fork.test.js +0 -81
  160. package/dist/src/game/scoring.test.js +0 -98
  161. package/dist/src/httpEmbedder.test.js +0 -277
  162. package/dist/src/importance.test.js +0 -372
  163. package/dist/src/intent.test.js +0 -78
  164. package/dist/src/log.test.js +0 -42
  165. package/dist/src/mechanical-fix.test.js +0 -65
  166. package/dist/src/memory.test.js +0 -70
  167. package/dist/src/memoryConsolidate.test.js +0 -38
  168. package/dist/src/memoryOps.test.js +0 -129
  169. package/dist/src/memoryRecall.test.js +0 -171
  170. package/dist/src/memoryRoundtrip.test.js +0 -137
  171. package/dist/src/metrics/turns.test.js +0 -152
  172. package/dist/src/mirror/mirror.test.js +0 -185
  173. package/dist/src/raptor-inject-summaries.test.js +0 -162
  174. package/dist/src/ratio.bench.test.js +0 -897
  175. package/dist/src/recall.integration.test.js +0 -77
  176. package/dist/src/recall.test.js +0 -379
  177. package/dist/src/sprint14.test.js +0 -219
  178. package/dist/src/sprint4x-rag-verification.test.js +0 -93
  179. package/dist/src/store/compression.test.js +0 -139
  180. package/dist/src/store/memoryIndex.test.js +0 -51
  181. package/dist/src/store/migrate.test.js +0 -139
  182. package/dist/src/store/pgOpenGuard.test.js +0 -74
  183. package/dist/src/store/phase01.test.js +0 -81
  184. package/dist/src/store/phase04.test.js +0 -54
  185. package/dist/src/store/sprint10.test.js +0 -186
  186. package/dist/src/store/sqlite/game-achievements.test.js +0 -67
  187. package/dist/src/store/sqlite/game-scores.test.js +0 -106
  188. package/dist/src/store/sqlite/game-state.test.js +0 -76
  189. package/dist/src/store/sqlite/global-index.test.js +0 -136
  190. package/dist/src/store/sqlite/mechanical-fix.test.js +0 -146
  191. package/dist/src/store/sqlite/perf-samples.test.js +0 -146
  192. package/dist/src/store/sqlite/raptor.test.js +0 -106
  193. package/dist/src/store/sqlite/turns.test.js +0 -183
  194. package/dist/src/store/sqlite.cachehit.test.js +0 -55
  195. package/dist/src/store/sqlite.dbmaint.test.js +0 -158
  196. package/dist/src/store/sqlite.dbmirror.test.js +0 -184
  197. package/dist/src/store/turns/contract-compliance.test.js +0 -428
  198. package/dist/src/store/turns/memory-store.test.js +0 -9
  199. package/dist/src/store/turns/migrations.test.js +0 -138
  200. package/dist/src/store/turns/sqlite-store.test.js +0 -144
  201. package/dist/src/store/vectorIndex.test.js +0 -123
  202. package/dist/src/store.test.js +0 -161
  203. package/dist/src/supersede.test.js +0 -36
  204. package/dist/src/topics/cluster.test.js +0 -150
  205. package/dist/src/topics/store.test.js +0 -117
  206. package/dist/src/types.test.js +0 -9
  207. package/dist/src/vector-search-cache.test.js +0 -157
  208. package/dist/src/vectorStore.test.js +0 -479
  209. package/dist/src/wiki.test.js +0 -171
  210. package/extensions/conflict-scan.test.ts +0 -129
  211. package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +0 -2
  212. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +0 -2
  213. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +0 -1
  214. package/extensions/dashboard-client/dist/assets/OverviewTab-D4kGvsiQ.js +0 -2
  215. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +0 -2
  216. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +0 -1
  217. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +0 -2
  218. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +0 -1
  219. package/extensions/dashboard-client/dist/assets/index-BX0bcgLu.js.map +0 -1
  220. package/extensions/dashboard-server/api-contracts.test.ts +0 -1065
  221. package/extensions/dashboard-server/html.test.ts +0 -62
  222. package/extensions/dashboard-server/perf-server.test.ts +0 -101
  223. package/extensions/dashboard-server/routes-cache.test.ts +0 -302
  224. package/extensions/dashboard-server/server.test.ts +0 -131
  225. package/extensions/dashboard-server-s32.test.ts +0 -195
  226. package/extensions/dashboard-server.test.ts +0 -382
  227. package/extensions/mega-cache-replay.test.ts +0 -526
  228. package/extensions/mega-compact-s38.test.ts +0 -872
  229. package/extensions/mega-compact.test.ts +0 -1509
  230. package/extensions/mega-events.test.ts +0 -55
  231. package/extensions/mega-game-cmds.test.ts +0 -197
  232. package/extensions/mega-runtime/snapshot.test.ts +0 -139
  233. package/extensions/mega-runtime/state.test.ts +0 -262
  234. package/extensions/mega-runtime/widget.test.ts +0 -468
  235. package/extensions/mega-shutdown-widget.test.ts +0 -141
  236. package/extensions/mega-teamrun.test.ts +0 -176
  237. package/extensions/mega-trim.test.ts +0 -64
  238. package/extensions/mega-turn-store.test.ts +0 -108
  239. package/src/boundary.test.ts +0 -187
  240. package/src/compact.test.ts +0 -99
  241. package/src/config/themes.test.ts +0 -116
  242. package/src/dedup/dedup.test.ts +0 -46
  243. package/src/dedup/l1.test.ts +0 -57
  244. package/src/dedup/raptor/buildHistory.test.ts +0 -353
  245. package/src/dedup/raptor/multilevel-serve.test.ts +0 -273
  246. package/src/dedup/raptor/multilevel.test.ts +0 -325
  247. package/src/dedup/raptor/promote.test.ts +0 -82
  248. package/src/dedup/raptor/raptor.test.ts +0 -297
  249. package/src/dedup/raptor/retrieval.test.ts +0 -118
  250. package/src/dedup/raptor/serve-gate.test.ts +0 -348
  251. package/src/dedup/sprint12.test.ts +0 -242
  252. package/src/dedup-engine.test.ts +0 -670
  253. package/src/driftDetection.test.ts +0 -100
  254. package/src/e2e.test.ts +0 -843
  255. package/src/engine.test.ts +0 -123
  256. package/src/extractive.test.ts +0 -174
  257. package/src/fork.test.ts +0 -94
  258. package/src/game/scoring.test.ts +0 -103
  259. package/src/httpEmbedder.test.ts +0 -286
  260. package/src/importance.test.ts +0 -538
  261. package/src/intent.test.ts +0 -87
  262. package/src/log.test.ts +0 -47
  263. package/src/mechanical-fix.test.ts +0 -70
  264. package/src/memory.test.ts +0 -92
  265. package/src/memoryConsolidate.test.ts +0 -47
  266. package/src/memoryOps.test.ts +0 -148
  267. package/src/memoryRecall.test.ts +0 -194
  268. package/src/memoryRoundtrip.test.ts +0 -155
  269. package/src/metrics/turns.test.ts +0 -165
  270. package/src/mirror/mirror.test.ts +0 -240
  271. package/src/raptor-inject-summaries.test.ts +0 -228
  272. package/src/ratio.bench.test.ts +0 -1316
  273. package/src/recall.integration.test.ts +0 -96
  274. package/src/recall.test.ts +0 -467
  275. package/src/sprint14.test.ts +0 -245
  276. package/src/sprint4x-rag-verification.test.ts +0 -119
  277. package/src/store/compression.test.ts +0 -168
  278. package/src/store/memoryIndex.test.ts +0 -61
  279. package/src/store/migrate.test.ts +0 -158
  280. package/src/store/pgOpenGuard.test.ts +0 -89
  281. package/src/store/phase01.test.ts +0 -88
  282. package/src/store/phase04.test.ts +0 -58
  283. package/src/store/sprint10.test.ts +0 -214
  284. package/src/store/sqlite/game-achievements.test.ts +0 -80
  285. package/src/store/sqlite/game-scores.test.ts +0 -132
  286. package/src/store/sqlite/game-state.test.ts +0 -89
  287. package/src/store/sqlite/global-index.test.ts +0 -222
  288. package/src/store/sqlite/mechanical-fix.test.ts +0 -162
  289. package/src/store/sqlite/perf-samples.test.ts +0 -169
  290. package/src/store/sqlite/raptor.test.ts +0 -139
  291. package/src/store/sqlite/turns.test.ts +0 -218
  292. package/src/store/sqlite.cachehit.test.ts +0 -68
  293. package/src/store/sqlite.dbmaint.test.ts +0 -193
  294. package/src/store/sqlite.dbmirror.test.ts +0 -229
  295. package/src/store/turns/contract-compliance.test.ts +0 -562
  296. package/src/store/turns/memory-store.test.ts +0 -15
  297. package/src/store/turns/migrations.test.ts +0 -161
  298. package/src/store/turns/sqlite-store.test.ts +0 -161
  299. package/src/store/vectorIndex.test.ts +0 -140
  300. package/src/store.test.ts +0 -191
  301. package/src/supersede.test.ts +0 -42
  302. package/src/topics/cluster.test.ts +0 -176
  303. package/src/topics/store.test.ts +0 -132
  304. package/src/types.test.ts +0 -10
  305. package/src/vector-search-cache.test.ts +0 -190
  306. package/src/vectorStore.test.ts +0 -533
  307. package/src/wiki.test.ts +0 -217
@@ -1,872 +0,0 @@
1
- /**
2
- * mega-compact.s38-error-retry.test.ts — S38 error-retry safety net tests.
3
- *
4
- * Extracted from mega-compact.test.ts so the error-retry feature has a focused,
5
- * fast-isolated test file. Exercises the real extension entry through a mock pi:
6
- * - classifyError classifier (transient / permanent / compaction-noop / null)
7
- * - turn_end retry nudges (max 5 transient, max 1 permanent)
8
- * - compaction-noop DOES NOT retry (pi race guard)
9
- * - MAX=0 disables transient retries
10
- * - mid-response errors (stream died without a stopReason)
11
- * - S38.5 race-guard (strict deferred vs synchronous)
12
- *
13
- * The harness is duplicated from mega-compact.test.ts (per guardrails guidance to
14
- * keep each test file self-contained; the harness is ~200 lines and does not
15
- * warrant a shared helper module that would itself exceed the 500-line target).
16
- */
17
- import { test } from "node:test";
18
- import assert from "node:assert/strict";
19
- import { mkdtempSync, rmSync } from "node:fs";
20
- import { tmpdir } from "node:os";
21
- import { join } from "node:path";
22
- import { createRequire } from "node:module";
23
- import { closeVectorIndex } from "../src/store/vectorIndex.js";
24
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
25
-
26
- const require = createRequire(import.meta.url);
27
- const baseTmp = mkdtempSync(join(tmpdir(), "mc-s38-"));
28
- // Isolate the machine-wide repo index so test runs never pollute the real one.
29
- process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
30
- let counter = 0;
31
-
32
- /** Read events.log and return an array of event type strings. */
33
- function eventTypes(stateDir: string): string[] {
34
- const { readFileSync: rf, existsSync: ex } =
35
- require("node:fs") as typeof import("node:fs");
36
- const { join: j } = require("node:path") as typeof import("node:path");
37
- const logPath = j(stateDir, "events.log");
38
- if (!ex(logPath)) return [];
39
- const content = rf(logPath, "utf-8").trim();
40
- if (content.length === 0) return [];
41
- return content
42
- .split("\n")
43
- .map((line) => { try { return JSON.parse(line).type; } catch { return undefined; } })
44
- .filter((t): t is string => typeof t === "string");
45
- }
46
-
47
- /** Build a mock pi + ctx and load the extension into them. */
48
- function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
49
- const stateDir = join(baseTmp, `run-${counter++}`);
50
- process.env.MEGACOMPACT_STATE_DIR = stateDir;
51
- process.env.MEGACOMPACT_DEBUG = "true";
52
- if (!opts.keepThreshold) process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
53
- if (!opts.keepTier) delete process.env.MEGACOMPACT_TIER;
54
- process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
55
-
56
- const handlers: Record<string, Function[]> = {};
57
- const appended: any[] = [];
58
- const sendUserMessages: string[] = [];
59
- const compactCalls: any[] = [];
60
-
61
- const session: AgentMessage[] = [
62
- { role: "user", content: "read src/vec.ts and understand the index", timestamp: 0 } as unknown as AgentMessage,
63
- { role: "assistant", content: [{ type: "toolCall", name: "Read", id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 } as unknown as AgentMessage,
64
- { role: "user", content: "edit src/vec.ts to add a cosine helper", timestamp: 0 } as unknown as AgentMessage,
65
- { role: "assistant", content: [{ type: "toolCall", name: "Edit", id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 } as unknown as AgentMessage,
66
- ];
67
- const toEntry = (m: AgentMessage, i: number): any => ({ type: "message", id: `e${i}`, parentId: null, timestamp: String(i), message: m });
68
- const sessionManager = { getSessionId: () => "sess_ext_001", getEntries: () => session.map(toEntry), getBranch: () => session.map(toEntry) };
69
-
70
- function makeCtx(over: Partial<any> = {}) {
71
- return {
72
- ui: { setStatus: () => {}, notify: () => {}, select: () => {}, confirm: async () => true, input: async () => "", setWidget: () => {} },
73
- mode: "tui" as any, hasUI: true, cwd: stateDir, sessionManager,
74
- modelRegistry: {} as any, model: undefined,
75
- isIdle: () => true, isProjectTrusted: () => true,
76
- signal: undefined, abort: () => {}, hasPendingMessages: () => false, shutdown: () => {},
77
- getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
78
- compact: (opts?: any) => {
79
- compactCalls.push(opts);
80
- const _sbc = handlers["session_before_compact"];
81
- if (_sbc && _sbc.length) return _sbc[0]({ type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: session.slice(0, 2), tokensBefore: 500 } } as any, makeCtx());
82
- return undefined;
83
- },
84
- getSystemPrompt: () => "system base", ...over,
85
- } as any;
86
- }
87
- const pi = {
88
- on: (ev: string, h: Function) => { if (!handlers[ev]) handlers[ev] = []; handlers[ev].push(h); },
89
- registerCommand: () => {}, registerTool: () => {}, registerShortcut: () => {},
90
- registerFlag: () => {}, getFlag: () => undefined,
91
- registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
92
- sendMessage: () => {}, sendUserMessage: (m: string) => { sendUserMessages.push(m); },
93
- appendEntry: (t: string, d: any) => appended.push({ t, d }),
94
- setSessionName: () => {}, getSessionName: () => undefined, setLabel: () => {},
95
- exec: async () => ({ stdout: "", stderr: "", code: 0 }),
96
- getActiveTools: () => [], getAllTools: () => {}, setActiveTools: () => {},
97
- getCommands: () => [], setModel: async () => false,
98
- getThinkingLevel: () => "off" as any, setThinkingLevel: () => {},
99
- } as any;
100
- const mod = require("./mega-compact.js") as { default: (p: any) => void };
101
- mod.default(pi);
102
- return {
103
- stateDir, handlers, appended, compactCalls, sendUserMessages,
104
- fire: async (ev: string, event: any, ctx: any) => { let r: any; for (const h of handlers[ev] || []) r = await h(event, ctx); return r; },
105
- ctx: makeCtx, session,
106
- };
107
- }
108
-
109
- const { classifyError: classifyErrorFn } =
110
- require("./mega-events.js") as { classifyError: typeof import("./mega-events.js").classifyError };
111
-
112
- /** S38 helper: fire a turn_end with a given stopReason + optional text, using a
113
- * low-pressure ctx so the durable-trim branch (ctx.compact) does NOT fire. */
114
- async function s38TurnEnd(h: ReturnType<typeof harness>, stopReason: string | undefined, text?: string) {
115
- const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
116
- const message: any = { role: "assistant" };
117
- if (stopReason !== undefined) message.stopReason = stopReason;
118
- if (text) message.content = text;
119
- await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
120
- }
121
-
122
- /** R3 helper: like s38TurnEnd but attaches a `usage` object so the classifier's
123
- * 0-token poisoned-context signal is exercised (usage PRESENT with 0 tokens).
124
- * Pass `tokens` > 0 to simulate a turn that reached the model. */
125
- async function s38TurnEndUsage(h: ReturnType<typeof harness>, stopReason: string | undefined, text: string | undefined, tokens: number) {
126
- const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
127
- const message: any = { role: "assistant", usage: { inputTokens: tokens, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } };
128
- if (stopReason !== undefined) message.stopReason = stopReason;
129
- if (text) message.content = text;
130
- await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
131
- }
132
-
133
- // ---- classifier unit tests (no extension harness needed) ----
134
-
135
- test("S38: classifyError returns 'transient' for error stopReason", () => {
136
- assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
137
- });
138
-
139
- test("S38: classifyError returns 'cancelled' for aborted stopReason (ESC/Ctrl-C)", () => {
140
- assert.equal(classifyErrorFn({ stopReason: "aborted" }), "cancelled");
141
- assert.equal(classifyErrorFn({ stopReason: "aborted", errorMessage: "Operation aborted" }), "cancelled");
142
- assert.equal(classifyErrorFn({ stopReason: "aborted", errorMessage: "Aborted after 3 retry attempts" }), "cancelled");
143
- });
144
-
145
- test("S38: classifyError does NOT return 'cancelled' for error message containing 'aborted' with non-aborted stopReason", () => {
146
- // Defense: the old text-based s.includes('aborted') path is gone,
147
- // but verify that an error message mentioning 'aborted' with stopReason
148
- // 'error' (not 'aborted') still classifies as transient, not cancelled.
149
- assert.equal(classifyErrorFn({ stopReason: "error", errorMessage: "Connection aborted" }), "transient");
150
- });
151
-
152
- test("S38: classifyError returns 'transient' for max-output-token text", () => {
153
- assert.equal(classifyErrorFn({ stopReason: "error", content: "reached the maximum output token limit" }), "transient");
154
- assert.equal(classifyErrorFn("max output token exceeded"), "transient");
155
- });
156
-
157
- test("S38: classifyError returns 'permanent' for auth/unauthorized text", () => {
158
- assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
159
- assert.equal(classifyErrorFn("permission denied"), "permanent");
160
- });
161
-
162
- test("S38: classifyError returns null for stop/toolUse stopReasons (success)", () => {
163
- assert.equal(classifyErrorFn({ stopReason: "stop" }), null);
164
- assert.equal(classifyErrorFn({ stopReason: "toolUse" }), null);
165
- assert.equal(classifyErrorFn({ stopReason: "tool_use" }), null);
166
- });
167
-
168
- test("S38: classifyError returns null for 'length' stopReason (S28 guard)", () => {
169
- assert.equal(classifyErrorFn({ stopReason: "length" }), null);
170
- });
171
-
172
- test("S38: classifyError returns 'transient' for mid-response errors with no stopReason", () => {
173
- assert.equal(classifyErrorFn({}), "transient");
174
- assert.equal(classifyErrorFn({ content: [] }), "transient");
175
- assert.equal(classifyErrorFn({ stopReason: undefined }), "transient");
176
- assert.equal(classifyErrorFn({ stopReason: "" }), "transient");
177
- });
178
-
179
- test("S38: classifyError returns 'transient' for error objects with message field", () => {
180
- assert.equal(classifyErrorFn({ error: { message: "Stream interrupted" } }), "transient");
181
- assert.equal(classifyErrorFn({ error: { message: "Connection lost" } }), "transient");
182
- assert.equal(classifyErrorFn({ error: { message: "500 Internal Server Error" } }), "transient");
183
- assert.equal(classifyErrorFn({ error: "Connection lost" }), "transient");
184
- });
185
-
186
- test("S38: classifyError returns 'transient' for mid-response stream failures in content", () => {
187
- assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Processing... Error: connection reset" }] }), "transient");
188
- assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Here is the answer..." }], stopReason: "error" }), "transient");
189
- });
190
-
191
- test("S38: classifyError returns 'transient' for partial content with NO stopReason (stream died after emitting text)", () => {
192
- // The mid-response disconnect case: provider streamed partial content then
193
- // died without a stopReason. MUST be transient (retryable), NOT null.
194
- assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "partial response..." }], stopReason: undefined }), "transient");
195
- assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }] }), "transient");
196
- assert.equal(classifyErrorFn({ role: "assistant", content: "partial response..." }), "transient");
197
- });
198
-
199
- test("S38: classifyError returns null for success stopReasons even with empty content", () => {
200
- assert.equal(classifyErrorFn({ stopReason: "stop", content: [] }), null);
201
- assert.equal(classifyErrorFn({ stopReason: "tool_use", content: [] }), null);
202
- });
203
-
204
- test("S38: classifyError returns 'compaction-noop' for 'Already compacted' text", () => {
205
- assert.equal(classifyErrorFn("Error: Already compacted"), "compaction-noop");
206
- });
207
-
208
- test("S38: classifyError returns 'compaction-noop' for 'Nothing to compact' text", () => {
209
- assert.equal(classifyErrorFn("Nothing to compact (session too small)"), "compaction-noop");
210
- });
211
-
212
- test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed' text", () => {
213
- assert.equal(classifyErrorFn("Auto compaction failed"), "compaction-noop");
214
- assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
215
- });
216
-
217
- // ---- context-overflow classifier (S38.8: 400 "too long... even after compaction") ----
218
-
219
- test("S38: classifyError returns 'context-overflow' for the literal user 400 string", () => {
220
- assert.equal(
221
- classifyErrorFn("Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction."),
222
- "context-overflow",
223
- );
224
- });
225
-
226
- test("S38: classifyError returns 'context-overflow' for 'All targets failed' wrapper", () => {
227
- assert.equal(
228
- classifyErrorFn("All targets failed: ... Last error: ... too long ... context window even after compaction"),
229
- "context-overflow",
230
- );
231
- });
232
-
233
- test("S38: classifyError returns 'context-overflow' for invalid_request_error JSON shape", () => {
234
- assert.equal(
235
- classifyErrorFn('{"type":"invalid_request_error","message":"... too long for context window ..."}'),
236
- "context-overflow",
237
- );
238
- });
239
-
240
- test("S38: classifyError returns 'context-overflow' for OpenRouter 'All targets failed' max-context 400 (FAIL-20260725)", () => {
241
- // Regression: the OpenAI/OpenRouter provider-side phrasing
242
- // "maximum context length is N tokens ... requires at least M tokens ...
243
- // reduce your input or max_tokens" does NOT contain "too long" / "context
244
- // window" / "reduce the conversation", so it slipped past the original S38.8
245
- // regex and fell through to the generic transient branch, firing 5 blind
246
- // retry nudges that re-submitted the same oversized prompt -> re-400 ->
247
- // busy-loop. The exact user-facing wrapper string from the router:
248
- assert.equal(
249
- classifyErrorFn('Error: 400: {"message":"All targets failed: neuralwatt/glm-5.2-short. Last error: This model\'s maximum context length is 200000 tokens. Your request requires at least 201070 tokens (201070 prompt + 0 max_tokens). Please reduce your input or max_tokens.","type":"invalid_request_error"}'),
250
- "context-overflow",
251
- );
252
- });
253
-
254
- test("S38: classifyError returns 'context-overflow' for bare 'maximum context length' phrasing", () => {
255
- assert.equal(
256
- classifyErrorFn("This model's maximum context length is 128000 tokens. Your request requires at least 130000 tokens. Please reduce your input or max_tokens."),
257
- "context-overflow",
258
- );
259
- });
260
-
261
- test("S38: classifyError returns 'context-overflow' for 'context length exceeded' phrasing", () => {
262
- assert.equal(
263
- classifyErrorFn("This model's context length exceeded: 200000 tokens"),
264
- "context-overflow",
265
- );
266
- });
267
-
268
- test("S38: context-overflow fires NO blind retry nudge and logs 'context_overflow'", async () => {
269
- const h = harness();
270
- await s38TurnEnd(
271
- h,
272
- "error",
273
- "Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction.",
274
- );
275
- const ev = eventTypes(h.stateDir);
276
- assert.equal(h.sendUserMessages.length, 0, "context-overflow: NO blind retry nudge fired");
277
- assert.ok(ev.includes("context_overflow"), "context-overflow: 'context_overflow' event logged");
278
- });
279
-
280
- test("S38: ESC-abort (stopReason='aborted') fires NO retry nudge and logs 'error_retry_cancelled'", async () => {
281
- const h = harness();
282
- await s38TurnEnd(h, "aborted", "Operation aborted");
283
- const ev = eventTypes(h.stateDir);
284
- assert.equal(h.sendUserMessages.length, 0, "cancelled: NO retry nudge fired after ESC abort");
285
- assert.ok(ev.includes("error_retry_cancelled"), "cancelled: 'error_retry_cancelled' event logged");
286
- });
287
-
288
- test("S38: ESC-abort resets errorRetryCount and consecutiveErrors so next transient retry starts fresh", async () => {
289
- const h = harness();
290
- // First: simulate an ESC abort (should reset both counters to 0)
291
- await s38TurnEnd(h, "aborted", "Operation aborted");
292
- assert.equal(h.sendUserMessages.length, 0, "cancelled: no nudge on abort");
293
- // Then: simulate a real transient error — should fire a fresh retry from count=1
294
- await s38TurnEnd(h, "error", "500 Internal Server Error");
295
- assert.equal(h.sendUserMessages.length, 1, "transient after cancel: retry fires from fresh count");
296
- });
297
-
298
- // ---- integration tests (fire turn_end through the real extension) ----
299
-
300
- test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
301
- const h = harness();
302
- await s38TurnEnd(h, "error", "Already compacted");
303
- const ev = eventTypes(h.stateDir);
304
- assert.ok(ev.includes("compaction_noop_diagnostic"), "compaction-noop: diagnostic event logged");
305
- assert.equal(h.sendUserMessages.length, 0, "compaction-noop: NO retry nudge fired");
306
- });
307
-
308
- test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", async () => {
309
- const h = harness();
310
- await s38TurnEnd(h, "error", "Nothing to compact");
311
- assert.equal(h.sendUserMessages.length, 0, "compaction-noop: no sendUserMessage");
312
- assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
313
- });
314
-
315
- test("S38: R1 burst of immediate transient errors fires 1 nudge (dedup), rest suppressed by retryNudgePending", async () => {
316
- // R1 redesign: a burst of immediate error turn_ends (no turn_start between)
317
- // produces ONE nudge — the rest are suppressed by retryNudgePending because
318
- // the queued nudge (deliverAs:'followUp') has not been consumed by a new
319
- // agent turn. errorRetryCount still advances for each error turn, so the
320
- // per-burst max + circuit breaker still bound the burst.
321
- const h = harness();
322
- for (let i = 0; i < 5; i++) await s38TurnEnd(h, "error", `internal server error ${i}`);
323
- assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
324
- // The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
325
- await s38TurnEnd(h, "error", "internal server error 5");
326
- assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
327
- assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
328
- assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
329
- });
330
-
331
- test("S38: retry fires 1x for permanent errors then stops", async () => {
332
- const h = harness();
333
- await s38TurnEnd(h, "error", "invalid api key");
334
- assert.equal(h.sendUserMessages.length, 1, "permanent: 1 retry nudge (<= max 1)");
335
- await s38TurnEnd(h, "error", "invalid api key");
336
- assert.equal(h.sendUserMessages.length, 1, "permanent: exhausted -> no 2nd nudge");
337
- assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "permanent exhausted logged");
338
- });
339
-
340
- test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
341
- // R1: backoff is now gating, so use a tiny backoff + small wait to let the
342
- // second nudge fire after the success reset clears retryNudgePending (R4).
343
- const prev = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
344
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
345
- try {
346
- const h = harness();
347
- await s38TurnEnd(h, "error", "5xx server error 0");
348
- assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
349
- await s38TurnEnd(h, "stop");
350
- assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending (R4)");
351
- await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
352
- await s38TurnEnd(h, "error", "5xx server error 1");
353
- assert.equal(h.sendUserMessages.length, 2, "success reset -> transient fires again from count=1");
354
- } finally {
355
- if (prev === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
356
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prev;
357
- }
358
- });
359
-
360
- test("S38: error_retry_exhausted event logged when max exceeded", async () => {
361
- // R3: "malformed bad request" is now poisoned-context (not permanent), so
362
- // use an auth-derived permanent error to exercise the per-burst exhausted path.
363
- const h = harness();
364
- await s38TurnEnd(h, "error", "unauthorized: invalid api key");
365
- await s38TurnEnd(h, "error", "unauthorized: invalid api key");
366
- assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
367
- });
368
-
369
- test("S38: MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX=0 disables transient retries cleanly", async () => {
370
- const prev = process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
371
- process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = "0";
372
- try {
373
- const h = harness();
374
- await s38TurnEnd(h, "error", "network timeout");
375
- assert.equal(h.sendUserMessages.length, 0, "max=0: no transient retry nudge");
376
- assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "max=0: no error_retry event");
377
- } finally {
378
- if (prev === undefined) delete process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
379
- else process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = prev;
380
- }
381
- });
382
-
383
- test("S38: retry fires for mid-response errors (no stopReason — stream died silently)", async () => {
384
- const h = harness();
385
- const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
386
- await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant" } }, lowCtx);
387
- assert.equal(h.sendUserMessages.length, 1, "mid-response silent failure: 1 retry nudge fired");
388
- assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for mid-response failure");
389
- });
390
-
391
- test("S38: retry fires for error objects with message field", async () => {
392
- const h = harness();
393
- const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
394
- await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", error: { message: "Connection reset by peer" } } }, lowCtx);
395
- assert.equal(h.sendUserMessages.length, 1, "error object with message: 1 retry nudge fired");
396
- });
397
-
398
- test("S38: retry fires for partial content with no stopReason (disconnect after emitting text)", async () => {
399
- // The post-resume disconnect case: provider streamed partial content then
400
- // died mid-stream with NO stopReason. Must fire a retry nudge.
401
- const h = harness();
402
- const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
403
- await h.fire("turn_end", {
404
- type: "turn_end",
405
- turnIndex: 1,
406
- message: { role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }], stopReason: undefined },
407
- }, lowCtx);
408
- assert.equal(h.sendUserMessages.length, 1, "partial-content mid-response failure: 1 retry nudge fired");
409
- assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for partial-content failure");
410
- });
411
-
412
- // ---- S38.5: race-guard strengthening (cooldown 10s->30s + deferred re-check) ---
413
-
414
- test("S38.5: MEGACOMPACT_RACE_GUARD_STRICT=false reverts to synchronous 10s guard", async () => {
415
- const prev = process.env.MEGACOMPACT_RACE_GUARD_STRICT;
416
- const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
417
- process.env.MEGACOMPACT_RACE_GUARD_STRICT = "false";
418
- process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
419
- try {
420
- const h = harness();
421
- const hiCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
422
- await h.fire("agent_end", { type: "agent_end", messages: [] }, hiCtx);
423
- assert.ok(h.compactCalls.length >= 1, "non-strict: synchronous ctx.compact() fired");
424
- } finally {
425
- if (prev === undefined) delete process.env.MEGACOMPACT_RACE_GUARD_STRICT;
426
- else process.env.MEGACOMPACT_RACE_GUARD_STRICT = prev;
427
- if (prevFloor === undefined) delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
428
- else process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
429
- }
430
- });
431
-
432
- test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", async () => {
433
- const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
434
- process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
435
- try {
436
- const h = harness();
437
- const hiCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
438
- await h.fire("agent_end", { type: "agent_end", messages: [] }, hiCtx);
439
- assert.equal(h.compactCalls.length, 0, "strict: ctx.compact() NOT called synchronously (deferred)");
440
- await new Promise((r) => setTimeout(r, 700));
441
- assert.ok(h.compactCalls.length >= 1, "strict: deferred ctx.compact() fired after re-check");
442
- } finally {
443
- if (prevFloor === undefined) delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
444
- else process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
445
- }
446
- });
447
-
448
- // ---- R3 classifier unit tests: poisoned-context signals ----
449
-
450
- test("R3 classifier: 0-token generic error (usage present, 0 tokens) → poisoned-context", () => {
451
- // The 2026-07-28 incident: stopReason 'error' + usage 0 tokens. The turn
452
- // never reached the model; retrying re-submits the same poisoned context.
453
- assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
454
- // Bare stopReason 'error' with NO usage field stays transient (unknown tokens
455
- // — conservative; preserves the pre-R3 mid-response/partial-content behavior).
456
- assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
457
- });
458
-
459
- test("R3 classifier: ECONNRESET (0-token) → transient (network failures stay transient)", () => {
460
- // R3: network failures must stay transient even with 0 tokens.
461
- assert.equal(classifyErrorFn({ stopReason: "error", content: "ECONNRESET", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
462
- assert.equal(classifyErrorFn({ stopReason: "error", content: "connection reset by peer", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
463
- assert.equal(classifyErrorFn({ stopReason: "error", content: "timeout", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
464
- assert.equal(classifyErrorFn({ stopReason: "error", content: "503 service unavailable", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
465
- });
466
-
467
- test("R3 classifier: 'request failed' generic (no transient marker) → poisoned-context", () => {
468
- // The exact incident phrasing: "Request failed — please retry." with no
469
- // specific transient cause → deterministic rejection.
470
- assert.equal(classifyErrorFn({ stopReason: "error", content: "Request failed — please retry.", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
471
- assert.equal(classifyErrorFn("request failed"), "poisoned-context");
472
- });
473
-
474
- test("R3 classifier: orphaned-tool-result 400 (non-overflow) → poisoned-context", () => {
475
- // Provider request-validation 400 that is NOT context-overflow: orphaned
476
- // tool result / malformed message structure. Previously 'permanent' (1
477
- // retry), now 'poisoned-context' (retry re-submits the same malformed shape).
478
- assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"orphaned tool result: tooluse ids mismatch"}'), "poisoned-context");
479
- assert.equal(classifyErrorFn("invalid request: unexpected role ordering"), "poisoned-context");
480
- assert.equal(classifyErrorFn("malformed message structure"), "poisoned-context");
481
- });
482
-
483
- test("R3 classifier: context-overflow phrasing still context-overflow (not poisoned)", () => {
484
- // Regression guard: the context-overflow check runs BEFORE the poisoned
485
- // signals, so a 400 "too long" stays context-overflow (forced re-compact),
486
- // not poisoned (advise + compact).
487
- assert.equal(
488
- classifyErrorFn({ stopReason: "error", content: "Your conversation is too long for this model's context window even after compaction.", usage: { inputTokens: 0, outputTokens: 0 } }),
489
- "context-overflow",
490
- );
491
- assert.equal(
492
- classifyErrorFn('{"type":"invalid_request_error","message":"maximum context length is 200000 tokens. requires at least 201070 tokens."}'),
493
- "context-overflow",
494
- );
495
- });
496
-
497
- test("R3 classifier: auth/permission stays permanent (not poisoned)", () => {
498
- // Auth errors are retryable-once (permanent), not poisoned — the user can
499
- // fix the key and retry.
500
- assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
501
- assert.equal(classifyErrorFn("permission denied"), "permanent");
502
- });
503
-
504
- // ---- R6 integration tests (retry redesign) ----
505
-
506
- test("R6(a): 10 consecutive identical 0-token transient failures produce at most errorRetrySessionMax nudges", async () => {
507
- // Use TRANSIENT 0-token failures (network text so they're transient, not
508
- // poisoned) + turn_start + tiny backoff between each so the nudge is
509
- // consumed and the next turn can fire. sessionMax default = 3. Repeat
510
- // threshold raised to disable the stateful poisoned upgrade so this
511
- // exercises the SESSION CAP, not the repeat signal.
512
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
513
- const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
514
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
515
- process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
516
- try {
517
- const h = harness();
518
- for (let i = 0; i < 10; i++) {
519
- // 0-token transient: usage present with 0 tokens + "connection reset" (network marker).
520
- await s38TurnEndUsage(h, "error", "connection reset", 0);
521
- await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
522
- await new Promise((r) => setTimeout(r, 3));
523
- }
524
- assert.ok(h.sendUserMessages.length <= 3, `R6(a): at most sessionMax (3) nudges, got ${h.sendUserMessages.length}`);
525
- assert.ok(eventTypes(h.stateDir).includes("error_retry_session_exhausted"), "session_exhausted event logged");
526
- } finally {
527
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
528
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
529
- if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
530
- else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
531
- }
532
- });
533
-
534
- test("R6(b): poisoned-context fires zero retry nudges and exactly one advise message", async () => {
535
- // auto=false so the guarded compact attempt (R3c) is skipped — this test
536
- // focuses on the advise + no-retry behavior. The compact path is the same
537
- // race-guarded deferred mechanism already covered by the context-overflow tests.
538
- const prevAuto = process.env.MEGACOMPACT_AUTO;
539
- process.env.MEGACOMPACT_AUTO = "false";
540
- try {
541
- const h = harness();
542
- // 0-token generic "request failed" (no transient marker) → poisoned.
543
- await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
544
- assert.equal(h.sendUserMessages.length, 1, "poisoned: exactly one advise message");
545
- assert.ok(
546
- h.sendUserMessages[0].includes("/clear") || h.sendUserMessages[0].includes("/new"),
547
- "advise mentions /clear or /new",
548
- );
549
- assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "poisoned_context event logged");
550
- assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "poisoned: zero retry nudges (no error_retry event)");
551
- // Second poisoned turn: advise throttled to one per session.
552
- await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
553
- assert.equal(h.sendUserMessages.length, 1, "poisoned: advise throttled (one per session)");
554
- assert.ok(eventTypes(h.stateDir).filter((t) => t === "poisoned_context").length >= 2, "poisoned_context logged each turn");
555
- } finally {
556
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
557
- else process.env.MEGACOMPACT_AUTO = prevAuto;
558
- }
559
- });
560
-
561
- test("R6(c): transient burst retries with backoff gating — second immediate nudge suppressed while one pending", async () => {
562
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
563
- const prevSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
564
- const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
565
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
566
- process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999"; // don't let session cap bind
567
- process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999"; // don't let repeat upgrade bind
568
- try {
569
- const h = harness();
570
- // Turn 1: transient → nudge 1 fires (pending=true, backoff=1ms).
571
- await s38TurnEnd(h, "error", "internal server error 0");
572
- assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
573
- // Turn 2: immediate (no turn_start) → suppressed by retryNudgePending.
574
- await s38TurnEnd(h, "error", "internal server error 1");
575
- assert.equal(h.sendUserMessages.length, 1, "second immediate nudge suppressed (retryNudgePending)");
576
- // turn_start consumes the pending nudge (resets pending + count).
577
- await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
578
- await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
579
- // Turn 3: transient → nudge 2 fires (pending cleared, backoff elapsed).
580
- await s38TurnEnd(h, "error", "internal server error 2");
581
- assert.equal(h.sendUserMessages.length, 2, "after turn_start + backoff: nudge fires");
582
- } finally {
583
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
584
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
585
- if (prevSession === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
586
- else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = prevSession;
587
- if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
588
- else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
589
- }
590
- });
591
-
592
- test("R6(d): user abort (stopReason aborted) never nudges, even across repeated aborts", async () => {
593
- const h = harness();
594
- await s38TurnEnd(h, "aborted", "Operation aborted");
595
- assert.equal(h.sendUserMessages.length, 0, "aborted: no nudge");
596
- await s38TurnEnd(h, "aborted", "Aborted after 3 retry attempts");
597
- assert.equal(h.sendUserMessages.length, 0, "aborted: still no nudge after repeated aborts");
598
- assert.ok(eventTypes(h.stateDir).includes("error_retry_cancelled"), "cancelled event logged");
599
- });
600
-
601
- test("R6(e): success resets retry-nudge-pending state", async () => {
602
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
603
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
604
- try {
605
- const h = harness();
606
- // Turn 1: transient → nudge fires, pending=true.
607
- await s38TurnEnd(h, "error", "internal server error 0");
608
- assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
609
- // Turn 2: immediate transient → suppressed by pending (no turn_start).
610
- await s38TurnEnd(h, "error", "internal server error 1");
611
- assert.equal(h.sendUserMessages.length, 1, "second immediate suppressed by pending");
612
- // Turn 3: success (stop) → resets pending (R4), no nudge.
613
- await s38TurnEnd(h, "stop");
614
- assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending");
615
- await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
616
- // Turn 4: transient → nudge fires again (pending was reset by success).
617
- await s38TurnEnd(h, "error", "internal server error 2");
618
- assert.equal(h.sendUserMessages.length, 2, "after success reset pending: transient nudge fires");
619
- } finally {
620
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
621
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
622
- }
623
- });
624
-
625
- test("R3: repeated identical transient error text upgrades to poisoned-context at threshold", async () => {
626
- // The stateful repeat signal: 3 consecutive identical transient errors
627
- // (default threshold) upgrade to poisoned. Uses "5xx server error" —
628
- // deliberately NO known-retryable marker ("5xx" is not /5\d\d/, and
629
- // "server error" alone matches nothing): the classifier returns transient
630
- // via the generic 'error' fallthrough, then the repeat tracker upgrades it.
631
- // auto=false to skip the compact attempt.
632
- const prevAuto = process.env.MEGACOMPACT_AUTO;
633
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
634
- process.env.MEGACOMPACT_AUTO = "false";
635
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
636
- try {
637
- const h = harness();
638
- // Turns 1-2: transient (repeatCount 1, 2) → nudges fire.
639
- await s38TurnEnd(h, "error", "5xx server error");
640
- await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
641
- await new Promise((r) => setTimeout(r, 3));
642
- await s38TurnEnd(h, "error", "5xx server error");
643
- await h.fire("turn_start", { type: "turn_start", turnIndex: 3 }, h.ctx());
644
- await new Promise((r) => setTimeout(r, 3));
645
- // Turn 3: repeatCount=3 ≥ threshold → upgraded to poisoned. No nudge, advise fires.
646
- await s38TurnEnd(h, "error", "5xx server error");
647
- assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "repeat threshold reached: poisoned_context event logged");
648
- assert.ok(h.sendUserMessages.some((m) => m.includes("/clear") || m.includes("/new")), "repeat threshold: advise message fired");
649
- } finally {
650
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
651
- else process.env.MEGACOMPACT_AUTO = prevAuto;
652
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
653
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
654
- }
655
- });
656
-
657
- // ---- R7 regression tests: network/throughput errors must NEVER upgrade to ----
658
- // ---- poisoned-context (2026-07-30 false-alarm incident) ----
659
-
660
- /** R7 helper: fire the same transient error text `count` times with turn_start
661
- * + elapsed backoff between turns so each turn's nudge can fire. */
662
- async function r7RepeatTurns(h: ReturnType<typeof harness>, text: string, count: number) {
663
- for (let i = 0; i < count; i++) {
664
- await s38TurnEnd(h, "error", text);
665
- await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
666
- await new Promise((r) => setTimeout(r, 3));
667
- }
668
- }
669
-
670
- /** R7 helper: shared assertions for "stays transient" — no poisoned upgrade,
671
- * no /clear advise, and the transient retry path actually ran. */
672
- function assertStaysTransient(h: ReturnType<typeof harness>, label: string) {
673
- assert.ok(
674
- !eventTypes(h.stateDir).includes("poisoned_context"),
675
- `${label}: no poisoned_context event (network errors never upgrade)`,
676
- );
677
- assert.ok(
678
- !h.sendUserMessages.some((m) => m.includes("/clear")),
679
- `${label}: no /clear advise message`,
680
- );
681
- assert.ok(
682
- eventTypes(h.stateDir).includes("error_retry"),
683
- `${label}: transient retry path ran (error_retry event)`,
684
- );
685
- }
686
-
687
- test("R7(a): repeated 'timed out' phrasing (2026-07-30 incident text) stays transient — no poisoned upgrade", async () => {
688
- // The incident error text was "Request timed out or failed. Try again" —
689
- // "timed out" (two words) does NOT match the old guard's /timeout/, so the
690
- // 3rd repeat fired the /clear poisoned advise. Must stay transient.
691
- const prevAuto = process.env.MEGACOMPACT_AUTO;
692
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
693
- process.env.MEGACOMPACT_AUTO = "false";
694
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
695
- try {
696
- const h = harness();
697
- await r7RepeatTurns(h, "Request timed out or failed. Try again", 3);
698
- assertStaysTransient(h, "timed out x3");
699
- } finally {
700
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
701
- else process.env.MEGACOMPACT_AUTO = prevAuto;
702
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
703
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
704
- }
705
- });
706
-
707
- test("R7(b): repeated ETIMEDOUT errno stays transient", async () => {
708
- // Node's timeout errno lowercases to "etimedout" — does NOT contain the
709
- // substring "timeout". Slipped through the old guard.
710
- const prevAuto = process.env.MEGACOMPACT_AUTO;
711
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
712
- process.env.MEGACOMPACT_AUTO = "false";
713
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
714
- try {
715
- const h = harness();
716
- await r7RepeatTurns(h, "connect ETIMEDOUT 142.250.80.46:443", 3);
717
- assertStaysTransient(h, "ETIMEDOUT x3");
718
- } finally {
719
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
720
- else process.env.MEGACOMPACT_AUTO = prevAuto;
721
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
722
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
723
- }
724
- });
725
-
726
- test("R7(c): repeated 'socket hang up' stays transient", async () => {
727
- // Node surfaces ECONNRESET as the message "socket hang up" (the errno lives
728
- // in error.code, which extractErrorSignature never sees).
729
- const prevAuto = process.env.MEGACOMPACT_AUTO;
730
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
731
- process.env.MEGACOMPACT_AUTO = "false";
732
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
733
- try {
734
- const h = harness();
735
- await r7RepeatTurns(h, "socket hang up", 3);
736
- assertStaysTransient(h, "socket hang up x3");
737
- } finally {
738
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
739
- else process.env.MEGACOMPACT_AUTO = prevAuto;
740
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
741
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
742
- }
743
- });
744
-
745
- test("R7(d): repeated 429 rate-limit stays transient", async () => {
746
- // /clear cannot fix a rate limit — the classifier explicitly keeps 429
747
- // transient, so the repeat-upgrade must not override that verdict.
748
- const prevAuto = process.env.MEGACOMPACT_AUTO;
749
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
750
- process.env.MEGACOMPACT_AUTO = "false";
751
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
752
- try {
753
- const h = harness();
754
- await r7RepeatTurns(h, "429 Too Many Requests: rate limit exceeded", 3);
755
- assertStaysTransient(h, "429 x3");
756
- } finally {
757
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
758
- else process.env.MEGACOMPACT_AUTO = prevAuto;
759
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
760
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
761
- }
762
- });
763
-
764
- test("R7(e): 0-token 'timed out' turn is NOT poisoned on first occurrence", async () => {
765
- // Deeper gap: with usage PRESENT at 0 tokens, the classifier's 0-token
766
- // poisoned signal fires on turn ONE for "timed out" phrasing (the stopReason
767
- // 'error' is in the text blob and no transient marker matched). The network
768
- // markers must be checked before that signal so this returns transient.
769
- const prevAuto = process.env.MEGACOMPACT_AUTO;
770
- process.env.MEGACOMPACT_AUTO = "false";
771
- try {
772
- const h = harness();
773
- await s38TurnEndUsage(h, "error", "Request timed out or failed. Try again", 0);
774
- assert.ok(
775
- !eventTypes(h.stateDir).includes("poisoned_context"),
776
- "0-token timed out: no poisoned_context on first turn",
777
- );
778
- assert.ok(
779
- !h.sendUserMessages.some((m) => m.includes("/clear")),
780
- "0-token timed out: no /clear advise on first turn",
781
- );
782
- } finally {
783
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
784
- else process.env.MEGACOMPACT_AUTO = prevAuto;
785
- }
786
- });
787
-
788
- test("R7(f): repeated non-network transient still upgrades to poisoned-context (control)", async () => {
789
- // True-positive control: an error with NO known-retryable marker that
790
- // repeats identically must still upgrade (the R3 feature itself).
791
- const prevAuto = process.env.MEGACOMPACT_AUTO;
792
- const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
793
- process.env.MEGACOMPACT_AUTO = "false";
794
- process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
795
- try {
796
- const h = harness();
797
- await r7RepeatTurns(h, "upstream rejected the request", 3);
798
- assert.ok(
799
- eventTypes(h.stateDir).includes("poisoned_context"),
800
- "non-network repeat: poisoned_context event logged",
801
- );
802
- assert.ok(
803
- h.sendUserMessages.some((m) => m.includes("/clear")),
804
- "non-network repeat: /clear advise fired",
805
- );
806
- } finally {
807
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
808
- else process.env.MEGACOMPACT_AUTO = prevAuto;
809
- if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
810
- else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
811
- }
812
- });
813
-
814
- test("R7(g): 'Request failed — please retry.' 0-token stays poisoned-context (2026-07-28 control)", async () => {
815
- // The deterministic-rejection incident that motivated R3 must remain
816
- // poisoned: the shared marker pattern must NOT match this text.
817
- const prevAuto = process.env.MEGACOMPACT_AUTO;
818
- process.env.MEGACOMPACT_AUTO = "false";
819
- try {
820
- const h = harness();
821
- await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
822
- assert.ok(
823
- eventTypes(h.stateDir).includes("poisoned_context"),
824
- "deterministic rejection: poisoned_context event logged",
825
- );
826
- assert.ok(h.sendUserMessages.some((m) => m.includes("/clear")), "deterministic rejection: /clear advise fired");
827
- } finally {
828
- if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
829
- else process.env.MEGACOMPACT_AUTO = prevAuto;
830
- }
831
- });
832
-
833
- test("R7 classifier: 0-token 'timed out' error → transient (not poisoned)", () => {
834
- // Unit-level pin of the R7(e) gap.
835
- assert.equal(
836
- classifyErrorFn({
837
- stopReason: "error",
838
- content: "Request timed out or failed. Try again",
839
- usage: { inputTokens: 0, outputTokens: 0 },
840
- }),
841
- "transient",
842
- );
843
- });
844
-
845
- test("R7 classifier: bare network phrasings → transient", () => {
846
- assert.equal(classifyErrorFn("socket hang up"), "transient");
847
- assert.equal(classifyErrorFn("the operation timed out"), "transient");
848
- assert.equal(classifyErrorFn("connect ETIMEDOUT 10.0.0.1:443"), "transient");
849
- });
850
-
851
- test("R7 classifier: 429/rate-limit → transient (control)", () => {
852
- assert.equal(classifyErrorFn("429 Too Many Requests: rate limit exceeded"), "transient");
853
- });
854
-
855
- test("cleanup", async () => {
856
- // PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
857
- try {
858
- await Promise.race([closeVectorIndex(), new Promise((r) => setTimeout(r, 3000))]);
859
- } catch { /* ignore */ }
860
- rmSync(baseTmp, { recursive: true, force: true });
861
- // Force-exit: each harness() creates a MegaRuntime with an fs.watch
862
- // game-state watcher that is never disposed (no session_shutdown in tests).
863
- // Those handles keep the event loop alive indefinitely after all tests
864
- // complete, so `node --test` (without --test-force-exit) would hang.
865
- // Drain stdout/stderr, then defer the exit: a bare process.exit() discards
866
- // unflushed pipe buffers — observed 2026-07-30: when piped, the trailing
867
- // tests' results and the run summary silently vanished from the report.
868
- await new Promise((r) => process.stdout.write("", r));
869
- await new Promise((r) => process.stderr.write("", r));
870
- await new Promise((r) => setTimeout(r, 1500));
871
- process.exit(0);
872
- });