switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -47,6 +47,8 @@ import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
47
47
  import {
48
48
  ORPHANED_REPLY_TIMEOUT_MS,
49
49
  ORPHANED_REPLY_MAX_REARMS,
50
+ ORPHANED_REPLY_STREAM_WINDOW_MS,
51
+ LivenessTracker,
50
52
  } from '../context-exhaustion.js'
51
53
 
52
54
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -524,3 +526,40 @@ describe('silence-poke — hard ceiling bounds the defer', () => {
524
526
  expect(__getStateForTests('chat:0')!.fallbackFired).toBe(true)
525
527
  })
526
528
  })
529
+
530
+ // ─── Thinking-pause liveness via the REAL LivenessTracker (orphaned-reply fix) ─
531
+
532
+ describe('silence-poke — thinking-pause liveness via the real LivenessTracker', () => {
533
+ // Drives the REAL LivenessTracker as the isLegitimatelyWorking source: a
534
+ // genuine stream event within ORPHANED_REPLY_STREAM_WINDOW_MS keeps the feed
535
+ // alive (a model reasoning pause is survivable); a gap longer than the window
536
+ // with no work tears the feed down.
537
+ it('a stream event within the window keeps the feed alive (thinking-pause survivable)', () => {
538
+ let clockNow = 0
539
+ const tracker = new LivenessTracker(0)
540
+ const f = setupSilenceDeps({
541
+ thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
542
+ isLegitimatelyWorking: () => tracker.recentlyStreaming(clockNow, ORPHANED_REPLY_STREAM_WINDOW_MS),
543
+ })
544
+ startTurn('chat:0', 0)
545
+ // A genuine stream event lands mid-turn (e.g. the model resumes after a
546
+ // reasoning pause) — only 50s before the 300s fallback tick.
547
+ tracker.onStreamEvent('text', undefined, 250_000)
548
+ clockNow = 300_000
549
+ __tickForTests(300_000) // 50s since last stream < 120s window → deferred
550
+ expect(f.fallbacks).toHaveLength(0)
551
+ })
552
+
553
+ it('no stream event for longer than the window tears the feed down', () => {
554
+ let clockNow = 0
555
+ const tracker = new LivenessTracker(0) // seed at t=0, nothing after
556
+ const f = setupSilenceDeps({
557
+ thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
558
+ isLegitimatelyWorking: () => tracker.recentlyStreaming(clockNow, ORPHANED_REPLY_STREAM_WINDOW_MS),
559
+ })
560
+ startTurn('chat:0', 0)
561
+ clockNow = 300_000
562
+ __tickForTests(300_000) // 300s since last stream >> 120s window → fires
563
+ expect(f.fallbacks).toHaveLength(1)
564
+ })
565
+ })
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
43
43
  // window to ~5KB so we don't pick up the unrelated
44
44
  // `removeTurnActiveMarker` call in the onTurnComplete handler
45
45
  // way down at the bottom of the file.
46
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 5000)
46
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
47
47
  const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
48
48
  expect(clearMatches.length).toBe(1)
49
49
  })
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
53
53
  // must not prevent the gateway from coming up. Pin the try-wrapping
54
54
  // to make sure a future refactor doesn't drop it.
55
55
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
56
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 5000)
56
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
57
57
  expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
58
58
  })
59
59
 
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
62
62
  // Clearing the marker first means the watchdog sees a clean slate
63
63
  // immediately, even if pin sweep takes 30s+ to finish.
64
64
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
65
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 5000)
65
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
66
66
  const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
67
67
  const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
68
68
  expect(clearIdx).toBeGreaterThan(0)
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Structural pins for the session-only model relaunch wiring in gateway.ts.
3
+ *
4
+ * The behaviour lives in un-exported inline closures (buildModelDeps's
5
+ * scheduleModelRelaunch, the model-menu callback sr-* branch, and the boot
6
+ * re-hydration / alert-sentinel block inside the startup IIFE), so — mirroring
7
+ * the other gateway-*.test.ts source-pins — we assert on the source structure.
8
+ * The end-to-end behaviour of the carrier itself is exercised in
9
+ * tests/scaffold.session-model-override.test.ts (rendered start.sh) and the
10
+ * handler contract in telegram-plugin/tests/model-command.test.ts.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { readFileSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { dirname, resolve } from 'node:path'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
20
+
21
+ describe('gateway: scheduleModelRelaunch dep', () => {
22
+ it('writes the carrier with exact "<model>\\n" bytes', () => {
23
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
24
+ expect(idx).toBeGreaterThan(0)
25
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
26
+ expect(win).toMatch(/writeFileSync\(\s*join\(agentDir, '\.session-model-override'\),\s*`\$\{model\}\\n`/)
27
+ })
28
+
29
+ it('sets the in-memory activeSessionModelOverride before dispatching the restart', () => {
30
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
31
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
32
+ const setIdx = win.indexOf('activeSessionModelOverride = model')
33
+ const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
34
+ expect(setIdx).toBeGreaterThan(0)
35
+ expect(restartIdx).toBeGreaterThan(setIdx)
36
+ })
37
+
38
+ it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
39
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
40
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
41
+ expect(win).toContain('await deps.scheduleRestart(reason)')
42
+ })
43
+ })
44
+
45
+ describe('gateway: model-menu sr-* target relaunches via the carrier', () => {
46
+ it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
47
+ const idx = GATEWAY_SRC.indexOf('if (data.startsWith(MODEL_CALLBACK_SR))')
48
+ expect(idx).toBeGreaterThan(0)
49
+ const win = GATEWAY_SRC.slice(idx, idx + 1400)
50
+ expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
51
+ // It must return before falling through to handleModelMenuCallback (which
52
+ // would inject an sr-* id claude's picker rejects).
53
+ expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
54
+ })
55
+ })
56
+
57
+ describe('gateway boot: session-model re-hydration + LiteLLM-down alert', () => {
58
+ it('re-hydrates activeSessionModelOverride from .active-session-model', () => {
59
+ const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
60
+ expect(idx).toBeGreaterThan(0)
61
+ const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
62
+ // Only an override when the launched model differs from the configured one.
63
+ expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
64
+ // The configured value must be resolved through resolveMainModel (the SAME
65
+ // resolver start.sh's scaffold uses) so an unset/`default` model config does
66
+ // not get flagged as a phantom session override on an ordinary restart.
67
+ expect(win).toContain('resolveMainModel(raw ?? undefined)')
68
+ })
69
+
70
+ it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
71
+ const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
72
+ expect(idx).toBeGreaterThan(0)
73
+ const win = GATEWAY_SRC.slice(idx, idx + 1100)
74
+ expect(win).toContain('unlinkSync(alertPath)')
75
+ // Broadcasts to every operator in allowFrom, not just allowFrom[0].
76
+ expect(win).toContain('const operators = loadAccess().allowFrom')
77
+ expect(win).toContain('for (const operator of operators)')
78
+ expect(win).toContain('lockedBot.api')
79
+ expect(win).toContain('.sendMessage(operator')
80
+ })
81
+ })
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Structural pin: the delivery state machine's `inbound` event MUST be
3
+ * emitted from `handleInbound` only AFTER the intercept/early-return
4
+ * gauntlet — never eagerly at handler entry.
5
+ *
6
+ * The wedge this guards (overlord `/usage` went dead, 2026-07-08). The
7
+ * inbound emit drives the now-AUTHORITATIVE turn-in-flight gate
8
+ * (turnInFlightForGate → isMachineInTurn). When it fired at handler entry
9
+ * — before the permission-reply / `/auth` paste-back / interrupt-empty /
10
+ * secret-detect-drop intercepts — an intercepted message (e.g. an approval
11
+ * reply) drove the machine idle→`bridge_alive_in_turn`, then early-returned
12
+ * as an intercept: no delivery, no claudeBusyKeys mark, and critically no
13
+ * `turnEnd`. The machine held the gate closed until the 5-min TTL tick
14
+ * force-cleared it, buffering every inbound (including `/usage`) meanwhile —
15
+ * the dangerous `machine_over_holds` divergence gate-parity-probe.ts flags.
16
+ *
17
+ * The behaviour lives inside the un-exported `handleInbound` closure, so —
18
+ * mirroring the other gateway-*.test.ts source-pins — we assert on source
19
+ * structure: the single inbound `shadowEmit` must sit below the intercepts
20
+ * and carry the real `isSteering` classification. Machine self-heal + gate
21
+ * accessors are behaviour-tested in inbound-delivery-cutover-gate.test.ts.
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest'
25
+ import { readFileSync } from 'node:fs'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { dirname, resolve } from 'node:path'
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url))
30
+ const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
31
+
32
+ /** Byte offset of the sole `shadowEmit({ kind: 'inbound' ... })` call. */
33
+ function inboundEmitOffset(): number {
34
+ const m = GATEWAY_SRC.match(/shadowEmit\(\{\s*\n\s*kind:\s*'inbound'/)
35
+ expect(m, 'expected exactly one multi-line inbound shadowEmit').not.toBeNull()
36
+ const idx = GATEWAY_SRC.indexOf(m![0])
37
+ // There must be only ONE such emit — a second eager site would reintroduce
38
+ // the wedge.
39
+ const second = GATEWAY_SRC.indexOf(m![0], idx + 1)
40
+ expect(second, 'a second inbound shadowEmit would reintroduce the eager-emit wedge').toBe(-1)
41
+ return idx
42
+ }
43
+
44
+ describe('gateway: inbound machine-emit is deferred past the intercepts', () => {
45
+ it('carries the DEFERRED_INBOUND_EMIT marker so the intent is greppable', () => {
46
+ expect(GATEWAY_SRC).toContain('DEFERRED_INBOUND_EMIT')
47
+ })
48
+
49
+ it('emits the machine `inbound` event AFTER the permission-reply intercept', () => {
50
+ const emitIdx = inboundEmitOffset()
51
+ // The permission-reply intercept is one of the early-return paths that
52
+ // must NOT drive the machine into a turn.
53
+ const permIdx = GATEWAY_SRC.indexOf('const permMatch = PERMISSION_REPLY_RE.exec(text)')
54
+ expect(permIdx).toBeGreaterThan(0)
55
+ expect(emitIdx).toBeGreaterThan(permIdx)
56
+ })
57
+
58
+ it('emits AFTER the `/auth add` paste-back intercept', () => {
59
+ const emitIdx = inboundEmitOffset()
60
+ const authIdx = GATEWAY_SRC.indexOf('pendingAuthAddFlows.get(interceptKey)')
61
+ expect(authIdx).toBeGreaterThan(0)
62
+ expect(emitIdx).toBeGreaterThan(authIdx)
63
+ })
64
+
65
+ it('emits AFTER isSteering is classified, and passes the real value (not a hardcoded false)', () => {
66
+ const emitIdx = inboundEmitOffset()
67
+ const steerIdx = GATEWAY_SRC.indexOf('isSteering = priorTurnInFlight && isSteerPrefix')
68
+ expect(steerIdx).toBeGreaterThan(0)
69
+ expect(emitIdx).toBeGreaterThan(steerIdx)
70
+ // The emit's msg object must forward the live `isSteering` binding, so a
71
+ // mid-turn steer is not mis-modelled as a fresh turn.
72
+ const win = GATEWAY_SRC.slice(emitIdx, emitIdx + 260)
73
+ expect(win).toMatch(/msg:\s*\{[\s\S]*?\bisSteering,[\s\S]*?\}/)
74
+ })
75
+
76
+ it('emits BEFORE the deliver-or-buffer decision (reserveInboundDelivery)', () => {
77
+ const emitIdx = inboundEmitOffset()
78
+ const gateIdx = GATEWAY_SRC.indexOf('const deliveryGate = reserveInboundDelivery({')
79
+ expect(gateIdx).toBeGreaterThan(0)
80
+ expect(emitIdx).toBeLessThan(gateIdx)
81
+ })
82
+ })
@@ -99,6 +99,48 @@ describe('spoolId — stable dedup key', () => {
99
99
  )
100
100
  expect(a).not.toBe(b)
101
101
  })
102
+ // #2793 part B: a cron BOOT-REPLAY carries meta.replay_fire_ms (the
103
+ // minute-aligned fire being replayed). spoolId() keys on it + the
104
+ // schedule index so a re-replay of the SAME missed fire across a gateway
105
+ // restart — which mints a fresh synthetic messageId=ts each boot —
106
+ // collapses to one live entry instead of stacking a fresh one each boot.
107
+ it('cron boot-replay → s:cron-replay:chat:idx:fireMs, stable across ts/messageId', () => {
108
+ const a = spoolId(
109
+ msg({
110
+ messageId: 1700_000_000_000,
111
+ ts: 1700_000_000_000,
112
+ meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '1699999980000' },
113
+ }),
114
+ )
115
+ const b = spoolId(
116
+ msg({
117
+ messageId: 1700_000_999_999,
118
+ ts: 1700_000_999_999,
119
+ meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '1699999980000' },
120
+ }),
121
+ )
122
+ expect(a).toBe('s:cron-replay:c1:3:1699999980000')
123
+ expect(b).toBe(a) // stable across the fresh per-boot messageId/ts
124
+ })
125
+ it('cron boot-replays for distinct missed fires stay distinct', () => {
126
+ const a = spoolId(
127
+ msg({ messageId: 0, meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '100' } }),
128
+ )
129
+ const b = spoolId(
130
+ msg({ messageId: 0, meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '200' } }),
131
+ )
132
+ const c = spoolId(
133
+ msg({ messageId: 0, meta: { source: 'cron', schedule_index: '4', replay_fire_ms: '100' } }),
134
+ )
135
+ expect(a).not.toBe(b) // different fire minute
136
+ expect(a).not.toBe(c) // different schedule index
137
+ })
138
+ it('a LIVE cron tick (no replay_fire_ms) keeps its per-fire identity', () => {
139
+ // Live ticks are not routed through the spool, but assert the id path
140
+ // is unchanged so live fires never collapse into a replay bucket.
141
+ const live = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 500 }))
142
+ expect(live).toBe('s:c1:cron:500')
143
+ })
102
144
  it('subagent_handback without jsonl id falls back to legacy id (back-compat)', () => {
103
145
  const a = spoolId(
104
146
  msg({ messageId: 555, meta: { source: 'subagent_handback' }, ts: 100 }),
@@ -578,3 +620,66 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
578
620
  expect(events).toEqual([true, false]) // degraded → recovered
579
621
  })
580
622
  })
623
+
624
+ // #2793 part B — cron boot-replay accept-vs-consume durability. A missed
625
+ // scheduled fire re-injected at boot is routed through this spool by the
626
+ // gateway's onInjectInbound handler (put on accept, ack on confirmed
627
+ // delivery). These tests pin the two windows the ledger closes.
628
+ describe('#2793 — cron boot-replay accept vs consume', () => {
629
+ function replayMsg(fireMs: number, boot: number): InboundMessage {
630
+ // A fresh synthetic messageId/ts is minted every replay attempt; the
631
+ // stable identity is (schedule_index, replay_fire_ms).
632
+ return msg({
633
+ messageId: boot,
634
+ ts: boot,
635
+ user: 'cron',
636
+ userId: 0,
637
+ meta: { source: 'cron', schedule_index: '3', replay_fire_ms: String(fireMs) },
638
+ })
639
+ }
640
+
641
+ it('accepted-but-NOT-consumed replay survives a restart and re-fires', () => {
642
+ const fs = fakeFs()
643
+ // Boot 1: the gateway accepts the replay (put) but the session never
644
+ // came up, so it is never delivered → never acked.
645
+ createInboundSpool({ path: PATH, fs, log: () => {} }).put('a', replayMsg(1000, 111))
646
+ // Boot 2: a fresh gateway rehydrates from the durable log. The un-acked
647
+ // replay is STILL live, so the boot-replay path re-delivers it — the
648
+ // silent-loss window (accepted, never consumed) is closed.
649
+ const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
650
+ const live = s2.liveEntries()
651
+ expect(live).toHaveLength(1)
652
+ expect(live[0]!.msg.meta?.replay_fire_ms).toBe('1000')
653
+ })
654
+
655
+ it('a CONSUMED replay (acked on delivery) does NOT re-fire on restart', () => {
656
+ const fs = fakeFs()
657
+ const s1 = createInboundSpool({ path: PATH, fs, log: () => {} })
658
+ const m = replayMsg(1000, 111)
659
+ s1.put('a', m)
660
+ s1.ack(m) // gateway delivered it to a live bridge → tombstone
661
+ // A fresh gateway sees the tombstone and does not re-deliver — the
662
+ // double-fire window is closed for a consumed replay.
663
+ const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
664
+ expect(s2.liveEntries()).toHaveLength(0)
665
+ })
666
+
667
+ it('a re-replay of the SAME missed fire dedups (at-least-once with dedup)', () => {
668
+ const fs = fakeFs()
669
+ const s = createInboundSpool({ path: PATH, fs, log: () => {} })
670
+ // First accept.
671
+ expect(s.put('a', replayMsg(1000, 111))).toBe(true)
672
+ // The scheduler re-replays the same missed fire on a later boot (fresh
673
+ // messageId/ts) while the first is still un-acked → collapses to one.
674
+ expect(s.put('a', replayMsg(1000, 222))).toBe(false)
675
+ expect(s.liveCount()).toBe(1)
676
+ })
677
+
678
+ it('distinct missed fires are each retained (no cross-fire collapse)', () => {
679
+ const fs = fakeFs()
680
+ const s = createInboundSpool({ path: PATH, fs, log: () => {} })
681
+ expect(s.put('a', replayMsg(1000, 111))).toBe(true)
682
+ expect(s.put('a', replayMsg(2000, 222))).toBe(true)
683
+ expect(s.liveCount()).toBe(2)
684
+ })
685
+ })
@@ -0,0 +1,228 @@
1
+ /**
2
+ * liveness-tracker.test.ts — drives the REAL LivenessTracker (the testability
3
+ * seam of the orphaned-reply thinking-pause fix).
4
+ *
5
+ * Root cause being fixed: the orphaned-reply fuse (ORPHANED_REPLY_TIMEOUT_MS =
6
+ * 30 s) was reset ONLY by `tool_label` / `text` events. During a long model
7
+ * reasoning pause the gateway sees NO such events (thinking carries no text),
8
+ * so the fuse ran down and force-ended a genuinely-live turn mid-work.
9
+ *
10
+ * The fix stamps `lastStreamEventAt` on ANY genuine stream event; if a genuine
11
+ * event landed within `windowMs` (default 120 s) the turn is "recently
12
+ * streaming" and the fuse re-arms instead of firing. A genuine multi-minute
13
+ * hang (no events at all) still fires.
14
+ *
15
+ * These tests use the REAL class — no replica helpers.
16
+ *
17
+ * NOTE on onStreamEvent's signature: it is `(kind, durationMs, now)`. The
18
+ * tracker is time-only — it never reads text content (the "Prompt is too long"
19
+ * marker matters to the gateway's context-exhaustion detection, not to the
20
+ * tracker). Where the fix spec wrote `onStreamEvent('text','Prompt is too
21
+ * long',0)` we pass `undefined` as durationMs (it is not a turn_end).
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest'
25
+ import {
26
+ LivenessTracker,
27
+ ORPHANED_REPLY_STREAM_WINDOW_MS,
28
+ ORPHANED_REPLY_TIMEOUT_MS,
29
+ ORPHANED_REPLY_MAX_REARMS,
30
+ } from '../context-exhaustion.js'
31
+
32
+ const W = ORPHANED_REPLY_STREAM_WINDOW_MS // 120_000
33
+ const MAX = ORPHANED_REPLY_MAX_REARMS // 20
34
+ const FUSE = ORPHANED_REPLY_TIMEOUT_MS // 30_000
35
+
36
+ function expiry(
37
+ t: LivenessTracker,
38
+ now: number,
39
+ opts?: { working?: boolean; humanWaiting?: boolean },
40
+ ) {
41
+ return t.decideOnExpiry({
42
+ working: opts?.working ?? false,
43
+ humanWaiting: opts?.humanWaiting ?? false,
44
+ now,
45
+ windowMs: W,
46
+ maxRearms: MAX,
47
+ })
48
+ }
49
+
50
+ describe('LivenessTracker — pinned constants', () => {
51
+ it('window is 120 s and fuse tick is 30 s', () => {
52
+ expect(W).toBe(120_000)
53
+ expect(FUSE).toBe(30_000)
54
+ expect(MAX).toBe(20)
55
+ })
56
+ })
57
+
58
+ describe('T1 — thinking-pause survival', () => {
59
+ it('text→tool_use→tool_result then a 45s stream gap keeps re-arming (gap < window)', () => {
60
+ const t = new LivenessTracker(0)
61
+ t.onStreamEvent('text', undefined, 0)
62
+ t.onStreamEvent('tool_use', undefined, 1_000)
63
+ t.onStreamEvent('tool_result', undefined, 2_000)
64
+
65
+ // +30s past the last event (t=32s): recently streaming → re-arm.
66
+ const d1 = expiry(t, 32_000)
67
+ expect(d1).toEqual({ rearm: true, countsAgainstCap: true })
68
+ expect(t.orphanedReplyRearmCount).toBe(1)
69
+
70
+ // +45s past the last event (t=47s): still within the 120s window → re-arm.
71
+ const d2 = expiry(t, 47_000)
72
+ expect(d2).toEqual({ rearm: true, countsAgainstCap: true })
73
+ expect(t.orphanedReplyRearmCount).toBe(2)
74
+ })
75
+
76
+ it('the next genuine stream event ZEROES the rearm counter and re-stamps liveness', () => {
77
+ const t = new LivenessTracker(0)
78
+ t.onStreamEvent('tool_result', undefined, 2_000)
79
+ expiry(t, 32_000)
80
+ expiry(t, 47_000)
81
+ expect(t.orphanedReplyRearmCount).toBe(2)
82
+
83
+ t.onStreamEvent('tool_label', undefined, 48_000)
84
+ expect(t.orphanedReplyRearmCount).toBe(0)
85
+ expect(t.lastStreamEventAt).toBe(48_000)
86
+ })
87
+
88
+ it('a synthetic turn_end (durationMs === -1) does NOT stamp or reset (F4 boundary)', () => {
89
+ const t = new LivenessTracker(0)
90
+ t.onStreamEvent('tool_label', undefined, 48_000)
91
+ expiry(t, 60_000) // counter → 1
92
+ expect(t.orphanedReplyRearmCount).toBe(1)
93
+
94
+ t.onStreamEvent('turn_end', -1, 70_000)
95
+ expect(t.lastStreamEventAt).toBe(48_000) // unchanged — synthetic excluded
96
+ expect(t.orphanedReplyRearmCount).toBe(1) // not reset
97
+
98
+ // A REAL turn_end (durationMs >= 0) DOES stamp — harmless, the turn is
99
+ // ending anyway (spec #3).
100
+ t.onStreamEvent('turn_end', 0, 71_000)
101
+ expect(t.lastStreamEventAt).toBe(71_000)
102
+ expect(t.orphanedReplyRearmCount).toBe(0)
103
+ })
104
+ })
105
+
106
+ describe('T2 — production repro (48s tool_result, 78s expiry)', () => {
107
+ it('text@0, tool_use@1s, tool_result@48s → expiry@78s re-arms (78-48=30 < 120)', () => {
108
+ const t = new LivenessTracker(0)
109
+ t.onStreamEvent('text', undefined, 0)
110
+ t.onStreamEvent('tool_use', undefined, 1_000)
111
+ t.onStreamEvent('tool_result', undefined, 48_000)
112
+
113
+ const d = expiry(t, 78_000)
114
+ expect(d.rearm).toBe(true)
115
+ expect(t.orphanedReplyRearmCount).toBe(1)
116
+
117
+ // tool_label@80s zeroes the counter (progress observed).
118
+ t.onStreamEvent('tool_label', undefined, 80_000)
119
+ expect(t.orphanedReplyRearmCount).toBe(0)
120
+ })
121
+ })
122
+
123
+ describe('T3 — long active turn (A2: counter reset is load-bearing)', () => {
124
+ it('tool_label every 60s + expiry every 60s+30s NEVER fires across 15 min; counter never exceeds 1', () => {
125
+ const t = new LivenessTracker(0)
126
+ for (let k = 0; k < 15; k++) {
127
+ t.onStreamEvent('tool_label', undefined, k * 60_000) // progress → reset
128
+ const d = expiry(t, k * 60_000 + 30_000, { working: true })
129
+ expect(d.rearm).toBe(true) // never fires
130
+ expect(t.orphanedReplyRearmCount).toBeLessThanOrEqual(1)
131
+ }
132
+ })
133
+
134
+ it('WITHOUT the progress reset the SAME cadence hits cap=20 near t=600s (proves reset load-bearing)', () => {
135
+ const t = new LivenessTracker(0)
136
+ let firedAt = -1
137
+ for (let k = 1; k <= 25; k++) {
138
+ const now = k * 30_000
139
+ // working:true so it would re-arm indefinitely IF the counter didn't
140
+ // accumulate — but with no onStreamEvent reset it climbs to the cap.
141
+ const d = expiry(t, now, { working: true })
142
+ if (!d.rearm) {
143
+ firedAt = now
144
+ break
145
+ }
146
+ }
147
+ // 20th re-arm lands at t=600s; the 21st expiry (t=630s) fires at the cap.
148
+ expect(t.orphanedReplyRearmCount).toBe(MAX)
149
+ expect(firedAt).toBeGreaterThanOrEqual(600_000)
150
+ })
151
+ })
152
+
153
+ describe('T4 — genuine hang caught (no events at all)', () => {
154
+ it('tool_result@0 then silence: re-arms via recentlyStreaming @30/60/90s, FIRES @120s (window lapsed)', () => {
155
+ const t = new LivenessTracker(0)
156
+ t.onStreamEvent('tool_result', undefined, 0)
157
+
158
+ for (const now of [30_000, 60_000, 90_000]) {
159
+ const d = expiry(t, now) // working:false — kept alive only by recentlyStreaming
160
+ expect(d.rearm).toBe(true)
161
+ }
162
+
163
+ // t=120s: now - lastStreamEventAt = 120_000 >= window → not recently
164
+ // streaming, working false → fail-safe FIRE.
165
+ const d = expiry(t, 120_000)
166
+ expect(d).toEqual({ rearm: false, countsAgainstCap: false })
167
+ })
168
+ })
169
+
170
+ describe('T5 — context-exhaustion recovery COMPLETES (not suppressed forever)', () => {
171
+ it('a "Prompt is too long" text event stamps liveness; defensive-guard predicate is true@30s, false@>=120s', () => {
172
+ const t = new LivenessTracker(0)
173
+ // The marker is a genuine `text` event (content is not a tracker concern).
174
+ t.onStreamEvent('text', undefined, 0)
175
+
176
+ // Context exhaustion means no tool is in flight: working === false. The
177
+ // defensive-guard suppression predicate is `working || recentlyStreaming`.
178
+ const working = false
179
+
180
+ // @30s: recentlyStreaming true → synthetic turn_end is suppressed. This
181
+ // DOCUMENTS the accepted ~30s→~120s recovery-latency growth (F3).
182
+ expect(working || t.recentlyStreaming(30_000, W)).toBe(true)
183
+
184
+ // @120s: window lapsed → NOT suppressed → teardown COMPLETES.
185
+ expect(working || t.recentlyStreaming(120_000, W)).toBe(false)
186
+
187
+ // @150s: still not suppressed — recovery is delayed, never forever.
188
+ expect(working || t.recentlyStreaming(150_000, W)).toBe(false)
189
+ })
190
+ })
191
+
192
+ describe('T7 — cap semantics', () => {
193
+ it('interleaved expiry/genuine-event: count 1 → 0 → 1 → 2', () => {
194
+ const t = new LivenessTracker(0)
195
+
196
+ expect(expiry(t, 10_000).rearm).toBe(true)
197
+ expect(t.orphanedReplyRearmCount).toBe(1)
198
+
199
+ t.onStreamEvent('tool_label', undefined, 11_000) // genuine event resets
200
+ expect(t.orphanedReplyRearmCount).toBe(0)
201
+
202
+ expiry(t, 20_000)
203
+ expect(t.orphanedReplyRearmCount).toBe(1)
204
+
205
+ expiry(t, 30_000) // consecutive silence (last event 11_000, gap 19s < 120s)
206
+ expect(t.orphanedReplyRearmCount).toBe(2)
207
+ })
208
+
209
+ it('21 consecutive recently-streaming expiries (no reset): 21st fires at the cap', () => {
210
+ const t = new LivenessTracker(0)
211
+ let last: { rearm: boolean; countsAgainstCap: boolean } | undefined
212
+ for (let k = 1; k <= 21; k++) {
213
+ // now = k*1000 keeps the gap from the seed (0) under the 120s window,
214
+ // so recentlyStreaming stays true throughout — the ONLY bound is the cap.
215
+ last = expiry(t, k * 1_000)
216
+ }
217
+ expect(last).toEqual({ rearm: false, countsAgainstCap: true })
218
+ expect(t.orphanedReplyRearmCount).toBe(MAX)
219
+
220
+ // humanWaiting past the cap → uncapped re-arm.
221
+ const dHuman = expiry(t, 22_000, { humanWaiting: true })
222
+ expect(dHuman).toEqual({ rearm: true, countsAgainstCap: false })
223
+
224
+ // recentlyStreaming false + working false + humanWaiting false → fail-safe FIRE.
225
+ const dFire = expiry(t, 200_000)
226
+ expect(dFire).toEqual({ rearm: false, countsAgainstCap: false })
227
+ })
228
+ })