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
@@ -1,21 +1,22 @@
1
1
  /**
2
- * Unit tests for the activity-feed-teardown fix (orphaned-reply backstop).
2
+ * Unit tests for the orphaned-reply backstop, repointed at the REAL
3
+ * LivenessTracker (the thinking-pause fix seam) — no replica decision helpers.
3
4
  *
4
5
  * Root cause: the orphaned-reply backstop fired a synthetic turn_end
5
- * (`durationMs: -1`) after 30 s of silence, even mid-tool-call. That nulled
6
+ * (`durationMs: -1`) after 30 s of silence, even mid-work. That nulled
6
7
  * `currentTurn` and dropped every subsequent `tool_label`, darkening the live
7
8
  * activity feed for the rest of the turn.
8
9
  *
9
- * Fix: three layers described in the PR.
10
- * PRIMARY — fuse fires mid-tool → re-arm instead (bounded by ORPHANED_REPLY_MAX_REARMS).
11
- * SECONDARY — tool_label re-arms the fuse so active label streams keep it fresh.
12
- * DEFENSIVE — turn_end entry rejects the synthetic event if tools are in flight.
13
- *
14
- * These tests cover the pure / unit-testable surfaces:
15
- * - shouldArmOrphanedReplyTimeout (existing, now with midToolCall param)
16
- * - ORPHANED_REPLY_MAX_REARMS constant math
17
- * - The re-arm guard logic (pure decision extracted from the closure)
18
- * - The defensive turn_end discriminator (durationMs === -1 + in-flight check)
10
+ * Fix layers (all now expressed through `LivenessTracker`):
11
+ * PRIMARY — the fuse fires mid-work → re-arm instead. `decideOnExpiry`
12
+ * re-arms while working OR recently-streaming OR human-waiting,
13
+ * bounded by ORPHANED_REPLY_MAX_REARMS for the working /
14
+ * recently-streaming cases (human-wait is uncapped).
15
+ * SECONDARY — a genuine stream event (`onStreamEvent`) re-stamps liveness and
16
+ * zeroes the rearm counter, so an actively-progressing turn never
17
+ * hits the cap.
18
+ * DEFENSIVE — the gateway rejects a synthetic turn_end while the turn is
19
+ * legitimately working OR recently streaming (`recentlyStreaming`).
19
20
  */
20
21
 
21
22
  import { describe, it, expect } from 'vitest'
@@ -23,37 +24,24 @@ import {
23
24
  shouldArmOrphanedReplyTimeout,
24
25
  ORPHANED_REPLY_TIMEOUT_MS,
25
26
  ORPHANED_REPLY_MAX_REARMS,
27
+ ORPHANED_REPLY_STREAM_WINDOW_MS,
28
+ LivenessTracker,
26
29
  } from '../context-exhaustion.js'
27
30
  import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
28
31
 
29
- // ---------------------------------------------------------------------------
30
- // Helpers — pure decision functions mirroring the gateway closure logic.
31
- // These extract the discriminable parts of the fix so they are unit-testable
32
- // without instantiating the full gateway.
33
- // ---------------------------------------------------------------------------
34
-
35
- /**
36
- * Mirrors the PRIMARY fix decision inside the setTimeout callback:
37
- * should the backstop re-arm (true) or fire turn_end (false)?
38
- */
39
- function shouldRearmInsteadOfFire(opts: {
40
- midToolCall: boolean
41
- rearmCount: number
42
- maxRearms: number
43
- }): boolean {
44
- return opts.midToolCall && opts.rearmCount < opts.maxRearms
45
- }
32
+ const W = ORPHANED_REPLY_STREAM_WINDOW_MS
33
+ const MAX = ORPHANED_REPLY_MAX_REARMS
46
34
 
47
35
  /**
48
- * Mirrors the DEFENSIVE fix at turn_end entry:
49
- * should a synthetic turn_end (durationMs === -1) be suppressed?
36
+ * A tracker whose last stream event is far enough in the past that
37
+ * `recentlyStreaming` is false at `now`, so the ONLY thing keeping the turn
38
+ * alive in a `decideOnExpiry` call is the `working` / `humanWaiting` inputs.
39
+ * Lets us test the "no liveness except the tool" decisions in isolation.
50
40
  */
51
- function shouldSuppressSyntheticTurnEnd(opts: {
52
- durationMs: number
53
- midToolCall: boolean
54
- }): boolean {
55
- return opts.durationMs === -1 && opts.midToolCall
41
+ function staleTracker(): LivenessTracker {
42
+ return new LivenessTracker(0)
56
43
  }
44
+ const STALE_NOW = W + 10_000 // gap > window → recentlyStreaming false
57
45
 
58
46
  // ---------------------------------------------------------------------------
59
47
  // Tests: ORPHANED_REPLY_MAX_REARMS constant
@@ -66,7 +54,6 @@ describe('ORPHANED_REPLY_MAX_REARMS', () => {
66
54
 
67
55
  it('combined with ORPHANED_REPLY_TIMEOUT_MS covers at least 10 min of tool activity', () => {
68
56
  const coverageMs = ORPHANED_REPLY_MAX_REARMS * ORPHANED_REPLY_TIMEOUT_MS
69
- // 20 × 30 000 ms = 600 000 ms = 10 min
70
57
  expect(coverageMs).toBeGreaterThanOrEqual(10 * 60 * 1000)
71
58
  })
72
59
 
@@ -76,165 +63,138 @@ describe('ORPHANED_REPLY_MAX_REARMS', () => {
76
63
  })
77
64
 
78
65
  // ---------------------------------------------------------------------------
79
- // Tests: PRIMARY fix — re-arm guard
66
+ // Tests: PRIMARY fix — re-arm via decideOnExpiry (REAL tracker)
80
67
  // ---------------------------------------------------------------------------
81
68
 
82
- describe('PRIMARY fix: re-arm guard (shouldRearmInsteadOfFire)', () => {
83
- it('re-arms when a tool is in flight and rearm count is under the cap', () => {
84
- expect(shouldRearmInsteadOfFire({ midToolCall: true, rearmCount: 0, maxRearms: 20 })).toBe(true)
85
- expect(shouldRearmInsteadOfFire({ midToolCall: true, rearmCount: 19, maxRearms: 20 })).toBe(true)
69
+ describe('PRIMARY fix: re-arm guard (LivenessTracker.decideOnExpiry)', () => {
70
+ it('re-arms while working and under the cap (recentlyStreaming false)', () => {
71
+ const t = staleTracker()
72
+ const d = t.decideOnExpiry({ working: true, humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX })
73
+ expect(d).toEqual({ rearm: true, countsAgainstCap: true })
86
74
  })
87
75
 
88
- it('fires once rearm count reaches the cap, even mid-tool-call', () => {
89
- expect(shouldRearmInsteadOfFire({ midToolCall: true, rearmCount: 20, maxRearms: 20 })).toBe(false)
90
- expect(shouldRearmInsteadOfFire({ midToolCall: true, rearmCount: 21, maxRearms: 20 })).toBe(false)
76
+ it('fires once the working rearm count reaches the cap (recentlyStreaming false)', () => {
77
+ const t = staleTracker()
78
+ let last: { rearm: boolean } | undefined
79
+ for (let k = 0; k <= MAX; k++) {
80
+ last = t.decideOnExpiry({ working: true, humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX })
81
+ }
82
+ // MAX re-arms proceeded (counter 1..MAX); the (MAX+1)th fires.
83
+ expect(last!.rearm).toBe(false)
84
+ expect(t.orphanedReplyRearmCount).toBe(MAX)
91
85
  })
92
86
 
93
- it('fires immediately when no tool is in flight, regardless of rearm count', () => {
94
- expect(shouldRearmInsteadOfFire({ midToolCall: false, rearmCount: 0, maxRearms: 20 })).toBe(false)
95
- expect(shouldRearmInsteadOfFire({ midToolCall: false, rearmCount: 5, maxRearms: 20 })).toBe(false)
87
+ it('fires immediately when nothing keeps the turn alive (idle, not recently streaming)', () => {
88
+ const t = staleTracker()
89
+ const d = t.decideOnExpiry({ working: false, humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX })
90
+ expect(d).toEqual({ rearm: false, countsAgainstCap: false })
96
91
  })
97
92
 
98
- it('rearm count transitions: 0 → cap-1 → cap fires', () => {
99
- const max = ORPHANED_REPLY_MAX_REARMS
100
- for (let i = 0; i < max; i++) {
101
- expect(shouldRearmInsteadOfFire({ midToolCall: true, rearmCount: i, maxRearms: max })).toBe(true)
102
- }
103
- // At exactly the cap: fire
104
- expect(shouldRearmInsteadOfFire({ midToolCall: true, rearmCount: max, maxRearms: max })).toBe(false)
93
+ it('re-arms while recently streaming even when NOT working (thinking-pause survival)', () => {
94
+ const t = new LivenessTracker(0)
95
+ t.onStreamEvent('text', undefined, 0)
96
+ const d = t.decideOnExpiry({ working: false, humanWaiting: false, now: 30_000, windowMs: W, maxRearms: MAX })
97
+ expect(d).toEqual({ rearm: true, countsAgainstCap: true })
105
98
  })
106
99
  })
107
100
 
108
101
  // ---------------------------------------------------------------------------
109
- // Tests: DEFENSIVE fix — synthetic turn_end suppressor
102
+ // Tests: DEFENSIVE fix — recentlyStreaming keeps the synthetic turn_end at bay
110
103
  // ---------------------------------------------------------------------------
111
104
 
112
- describe('DEFENSIVE fix: synthetic turn_end suppressor', () => {
113
- it('suppresses a synthetic turn_end (durationMs === -1) when tools are in flight', () => {
114
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: -1, midToolCall: true })).toBe(true)
115
- })
116
-
117
- it('does NOT suppress a synthetic turn_end when no tools are in flight', () => {
118
- // No tools → the backstop should fire normally (turn is genuinely orphaned)
119
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: -1, midToolCall: false })).toBe(false)
105
+ describe('DEFENSIVE fix: recentlyStreaming suppression window', () => {
106
+ it('is true within the window of the last genuine stream event', () => {
107
+ const t = new LivenessTracker(0)
108
+ t.onStreamEvent('tool_use', undefined, 5_000)
109
+ expect(t.recentlyStreaming(5_000, W)).toBe(true)
110
+ expect(t.recentlyStreaming(5_000 + W - 1, W)).toBe(true)
120
111
  })
121
112
 
122
- it('does NOT suppress an authoritative turn_end (durationMs >= 0)', () => {
123
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: 0, midToolCall: true })).toBe(false)
124
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: 1, midToolCall: true })).toBe(false)
125
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: 12345, midToolCall: true })).toBe(false)
126
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: 0, midToolCall: false })).toBe(false)
113
+ it('is false once the window lapses (genuine hang, teardown proceeds)', () => {
114
+ const t = new LivenessTracker(0)
115
+ t.onStreamEvent('tool_use', undefined, 5_000)
116
+ expect(t.recentlyStreaming(5_000 + W, W)).toBe(false)
127
117
  })
128
118
 
129
- it('only durationMs === -1 is the synthetic discriminator', () => {
130
- // Values near -1 must not accidentally trigger suppression
131
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: -2, midToolCall: true })).toBe(false)
132
- expect(shouldSuppressSyntheticTurnEnd({ durationMs: -0.5, midToolCall: true })).toBe(false)
119
+ it('a synthetic turn_end (-1) does not extend the window', () => {
120
+ const t = new LivenessTracker(0)
121
+ t.onStreamEvent('tool_use', undefined, 5_000)
122
+ t.onStreamEvent('turn_end', -1, 5_500) // ignored
123
+ expect(t.lastStreamEventAt).toBe(5_000)
124
+ expect(t.recentlyStreaming(5_000 + W, W)).toBe(false)
133
125
  })
134
126
  })
135
127
 
136
128
  // ---------------------------------------------------------------------------
137
- // Tests: ToolFlightTracker integration with the guard logic
129
+ // Tests: ToolFlightTracker → working input → decideOnExpiry
138
130
  // ---------------------------------------------------------------------------
139
131
 
140
- describe('ToolFlightTracker + guard integration', () => {
141
- it('re-arm fires when a Bash tool is in flight', () => {
142
- const tracker = new ToolFlightTracker()
143
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'bash_1' })
144
-
145
- expect(shouldRearmInsteadOfFire({
146
- midToolCall: tracker.isMidToolCall(),
147
- rearmCount: 0,
148
- maxRearms: ORPHANED_REPLY_MAX_REARMS,
149
- })).toBe(true)
150
- })
151
-
152
- it('fires normally after tool_result completes the tool', () => {
153
- const tracker = new ToolFlightTracker()
154
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'bash_1' })
155
- tracker.onEvent({ kind: 'tool_result', toolUseId: 'bash_1' })
156
-
157
- expect(shouldRearmInsteadOfFire({
158
- midToolCall: tracker.isMidToolCall(),
159
- rearmCount: 0,
160
- maxRearms: ORPHANED_REPLY_MAX_REARMS,
161
- })).toBe(false)
162
- })
163
-
164
- it('defensive guard suppresses synthetic turn_end mid-Bash', () => {
165
- const tracker = new ToolFlightTracker()
166
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'bash_2' })
167
-
168
- expect(shouldSuppressSyntheticTurnEnd({
169
- durationMs: -1,
170
- midToolCall: tracker.isMidToolCall(),
171
- })).toBe(true)
172
- })
173
-
174
- it('defensive guard allows synthetic turn_end after all tools complete', () => {
175
- const tracker = new ToolFlightTracker()
176
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'bash_2' })
177
- tracker.onEvent({ kind: 'tool_result', toolUseId: 'bash_2' })
178
-
179
- expect(shouldSuppressSyntheticTurnEnd({
180
- durationMs: -1,
181
- midToolCall: tracker.isMidToolCall(),
182
- })).toBe(false)
132
+ describe('ToolFlightTracker drives the `working` input', () => {
133
+ it('re-arm fires when a Bash tool is in flight (working=true)', () => {
134
+ const flight = new ToolFlightTracker()
135
+ flight.onEvent({ kind: 'tool_use', toolUseId: 'bash_1' })
136
+ const t = staleTracker()
137
+ const d = t.decideOnExpiry({
138
+ working: flight.isMidToolCall(),
139
+ humanWaiting: false,
140
+ now: STALE_NOW,
141
+ windowMs: W,
142
+ maxRearms: MAX,
143
+ })
144
+ expect(d.rearm).toBe(true)
145
+ })
146
+
147
+ it('fires normally after tool_result completes the tool (working=false, not recently streaming)', () => {
148
+ const flight = new ToolFlightTracker()
149
+ flight.onEvent({ kind: 'tool_use', toolUseId: 'bash_1' })
150
+ flight.onEvent({ kind: 'tool_result', toolUseId: 'bash_1' })
151
+ const t = staleTracker()
152
+ const d = t.decideOnExpiry({
153
+ working: flight.isMidToolCall(),
154
+ humanWaiting: false,
155
+ now: STALE_NOW,
156
+ windowMs: W,
157
+ maxRearms: MAX,
158
+ })
159
+ expect(d.rearm).toBe(false)
183
160
  })
184
161
 
185
162
  it('parallel tools: re-arm persists while ANY tool is in flight', () => {
186
- const tracker = new ToolFlightTracker()
187
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'read_1' })
188
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'read_2' })
189
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'edit_1' })
190
-
191
- // Still re-arming: 3 tools open
192
- expect(shouldRearmInsteadOfFire({
193
- midToolCall: tracker.isMidToolCall(),
194
- rearmCount: 0,
195
- maxRearms: ORPHANED_REPLY_MAX_REARMS,
196
- })).toBe(true)
197
-
198
- // Two complete
199
- tracker.onEvent({ kind: 'tool_result', toolUseId: 'read_1' })
200
- tracker.onEvent({ kind: 'tool_result', toolUseId: 'read_2' })
201
-
202
- // Still re-arming: edit_1 open
203
- expect(shouldRearmInsteadOfFire({
204
- midToolCall: tracker.isMidToolCall(),
205
- rearmCount: 1,
206
- maxRearms: ORPHANED_REPLY_MAX_REARMS,
207
- })).toBe(true)
163
+ const flight = new ToolFlightTracker()
164
+ flight.onEvent({ kind: 'tool_use', toolUseId: 'read_1' })
165
+ flight.onEvent({ kind: 'tool_use', toolUseId: 'read_2' })
166
+ flight.onEvent({ kind: 'tool_use', toolUseId: 'edit_1' })
167
+ const t = staleTracker()
168
+ expect(
169
+ t.decideOnExpiry({ working: flight.isMidToolCall(), humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX }).rearm,
170
+ ).toBe(true)
208
171
 
209
- // All complete
210
- tracker.onEvent({ kind: 'tool_result', toolUseId: 'edit_1' })
172
+ flight.onEvent({ kind: 'tool_result', toolUseId: 'read_1' })
173
+ flight.onEvent({ kind: 'tool_result', toolUseId: 'read_2' })
174
+ expect(
175
+ t.decideOnExpiry({ working: flight.isMidToolCall(), humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX }).rearm,
176
+ ).toBe(true) // edit_1 still open
211
177
 
212
- expect(shouldRearmInsteadOfFire({
213
- midToolCall: tracker.isMidToolCall(),
214
- rearmCount: 2,
215
- maxRearms: ORPHANED_REPLY_MAX_REARMS,
216
- })).toBe(false)
178
+ flight.onEvent({ kind: 'tool_result', toolUseId: 'edit_1' })
179
+ const t2 = staleTracker() // fresh so the earlier rearms don't confound the cap
180
+ expect(
181
+ t2.decideOnExpiry({ working: flight.isMidToolCall(), humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX }).rearm,
182
+ ).toBe(false) // all complete, not recently streaming → fire
217
183
  })
218
184
 
219
185
  it('cap fires even mid-tool after 20 re-arms (wedged tool surfaces)', () => {
220
- const tracker = new ToolFlightTracker()
221
- tracker.onEvent({ kind: 'tool_use', toolUseId: 'hung_bash' })
222
-
223
- // First 20 re-arms proceed
224
- for (let i = 0; i < ORPHANED_REPLY_MAX_REARMS; i++) {
225
- expect(shouldRearmInsteadOfFire({
226
- midToolCall: tracker.isMidToolCall(),
227
- rearmCount: i,
228
- maxRearms: ORPHANED_REPLY_MAX_REARMS,
229
- })).toBe(true)
186
+ const flight = new ToolFlightTracker()
187
+ flight.onEvent({ kind: 'tool_use', toolUseId: 'hung_bash' })
188
+ const t = staleTracker()
189
+ for (let i = 0; i < MAX; i++) {
190
+ expect(
191
+ t.decideOnExpiry({ working: flight.isMidToolCall(), humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX }).rearm,
192
+ ).toBe(true)
230
193
  }
231
-
232
- // 21st: cap exceeded — fire despite in-flight
233
- expect(shouldRearmInsteadOfFire({
234
- midToolCall: tracker.isMidToolCall(),
235
- rearmCount: ORPHANED_REPLY_MAX_REARMS,
236
- maxRearms: ORPHANED_REPLY_MAX_REARMS,
237
- })).toBe(false)
194
+ // Cap exceeded — fire despite in-flight.
195
+ expect(
196
+ t.decideOnExpiry({ working: flight.isMidToolCall(), humanWaiting: false, now: STALE_NOW, windowMs: W, maxRearms: MAX }).rearm,
197
+ ).toBe(false)
238
198
  })
239
199
  })
240
200
 
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Unit tests for the bridge-side outstanding permission-request ledger
3
+ * (#2861 / #2859).
4
+ *
5
+ * The ledger is the primitive that closes R2 (bridge dropped permission
6
+ * requests while the gateway IPC was down). bridge.ts wires it as:
7
+ * - permission_request notification → ledger.add(params) (BEFORE the
8
+ * connect check), then send if connected else buffer.
9
+ * - onPermission (verdict delivered) → ledger.delete(requestId).
10
+ * - onConnect (IPC (re)connect) → re-send ledger.all().
11
+ *
12
+ * bridge.ts has top-level side effects and isn't unit-importable, so here we
13
+ * (a) unit-test the ledger primitive directly and (b) drive a faithful
14
+ * re-implementation of the three wiring points against a fake IPC to prove the
15
+ * buffer / re-send / delete behaviours. The source wiring itself is pinned in
16
+ * permission-rearm-wiring.test.ts.
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest'
20
+ import {
21
+ createOutstandingPermissionLedger,
22
+ type OutstandingPermissionParams,
23
+ } from '../bridge/permission-ledger.js'
24
+
25
+ function params(requestId: string, tool = 'mcp__brevo__post'): OutstandingPermissionParams {
26
+ return {
27
+ request_id: requestId,
28
+ tool_name: tool,
29
+ description: `${tool} description`,
30
+ input_preview: '{"foo":"bar"}',
31
+ }
32
+ }
33
+
34
+ describe('createOutstandingPermissionLedger', () => {
35
+ it('starts empty', () => {
36
+ const led = createOutstandingPermissionLedger()
37
+ expect(led.size).toBe(0)
38
+ expect(led.all()).toEqual([])
39
+ })
40
+
41
+ it('add + all + has', () => {
42
+ const led = createOutstandingPermissionLedger()
43
+ led.add(params('aaaaa'))
44
+ led.add(params('bbbbb'))
45
+ expect(led.size).toBe(2)
46
+ expect(led.has('aaaaa')).toBe(true)
47
+ expect(led.has('zzzzz')).toBe(false)
48
+ expect(led.all().map(p => p.request_id)).toEqual(['aaaaa', 'bbbbb'])
49
+ })
50
+
51
+ it('add is idempotent on request_id (latest params win)', () => {
52
+ const led = createOutstandingPermissionLedger()
53
+ led.add(params('aaaaa', 'mcp__brevo__post'))
54
+ led.add(params('aaaaa', 'mcp__brevo__patch'))
55
+ expect(led.size).toBe(1)
56
+ expect(led.all()[0].tool_name).toBe('mcp__brevo__patch')
57
+ })
58
+
59
+ it('delete removes the entry (verdict delivered)', () => {
60
+ const led = createOutstandingPermissionLedger()
61
+ led.add(params('aaaaa'))
62
+ led.delete('aaaaa')
63
+ expect(led.size).toBe(0)
64
+ expect(led.has('aaaaa')).toBe(false)
65
+ })
66
+
67
+ it('delete of an unknown id is a no-op', () => {
68
+ const led = createOutstandingPermissionLedger()
69
+ led.add(params('aaaaa'))
70
+ led.delete('nope')
71
+ expect(led.size).toBe(1)
72
+ })
73
+ })
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Faithful re-implementation of the three bridge wiring points. Mirrors
77
+ // bridge.ts exactly so the buffer / re-send / delete contract is exercised
78
+ // without importing the side-effecting module.
79
+ // ---------------------------------------------------------------------------
80
+
81
+ interface FakeIpc {
82
+ connected: boolean
83
+ sent: Array<{ requestId: string }>
84
+ }
85
+
86
+ function makeHarness(rearmEnabled = true) {
87
+ const ledger = createOutstandingPermissionLedger()
88
+ const ipc: FakeIpc = { connected: false, sent: [] }
89
+
90
+ // Mirrors the permission_request notification handler.
91
+ function onPermissionRequest(p: OutstandingPermissionParams): void {
92
+ if (rearmEnabled) ledger.add(p)
93
+ if (!ipc.connected) {
94
+ // BUFFER (not drop) when re-arm enabled.
95
+ return
96
+ }
97
+ ipc.sent.push({ requestId: p.request_id })
98
+ }
99
+
100
+ // Mirrors onPermission (verdict delivered).
101
+ function onVerdict(requestId: string): void {
102
+ ledger.delete(requestId)
103
+ }
104
+
105
+ // Mirrors flushOutstandingPermissionRequests on onConnect.
106
+ function onConnect(): void {
107
+ ipc.connected = true
108
+ if (!rearmEnabled) return
109
+ for (const p of ledger.all()) ipc.sent.push({ requestId: p.request_id })
110
+ }
111
+
112
+ function disconnect(): void {
113
+ ipc.connected = false
114
+ }
115
+
116
+ return { ledger, ipc, onPermissionRequest, onVerdict, onConnect, disconnect }
117
+ }
118
+
119
+ describe('bridge permission-request wiring (buffer / re-send / delete)', () => {
120
+ it('buffers when disconnected — no drop, request retained', () => {
121
+ const h = makeHarness()
122
+ h.onPermissionRequest(params('aaaaa'))
123
+ // Nothing sent (gateway down) but the ledger retained it.
124
+ expect(h.ipc.sent).toEqual([])
125
+ expect(h.ledger.has('aaaaa')).toBe(true)
126
+ })
127
+
128
+ it('re-sends every outstanding request on (re)connect', () => {
129
+ const h = makeHarness()
130
+ h.onPermissionRequest(params('aaaaa'))
131
+ h.onPermissionRequest(params('bbbbb'))
132
+ expect(h.ipc.sent).toEqual([]) // still down
133
+
134
+ h.onConnect() // gateway comes up
135
+ expect(h.ipc.sent.map(s => s.requestId).sort()).toEqual(['aaaaa', 'bbbbb'])
136
+ })
137
+
138
+ it('re-sends again after a disconnect/reconnect cycle (gateway restart)', () => {
139
+ const h = makeHarness()
140
+ h.onConnect() // connected
141
+ h.onPermissionRequest(params('aaaaa')) // sent live
142
+ expect(h.ipc.sent.map(s => s.requestId)).toEqual(['aaaaa'])
143
+
144
+ h.disconnect() // gateway restarts
145
+ h.onConnect() // reconnect → re-send outstanding
146
+ expect(h.ipc.sent.map(s => s.requestId)).toEqual(['aaaaa', 'aaaaa'])
147
+ })
148
+
149
+ it('a delivered verdict removes the entry — no re-send after resolution', () => {
150
+ const h = makeHarness()
151
+ h.onPermissionRequest(params('aaaaa'))
152
+ h.onVerdict('aaaaa') // operator tapped, verdict delivered
153
+ expect(h.ledger.has('aaaaa')).toBe(false)
154
+
155
+ h.onConnect() // reconnect
156
+ expect(h.ipc.sent).toEqual([]) // nothing re-sent
157
+ })
158
+
159
+ it('kill switch: disabled → drop-when-disconnected, no ledger, no re-send', () => {
160
+ const h = makeHarness(false)
161
+ h.onPermissionRequest(params('aaaaa')) // disconnected → dropped, not recorded
162
+ expect(h.ledger.size).toBe(0)
163
+ h.onConnect()
164
+ expect(h.ipc.sent).toEqual([]) // nothing to re-send
165
+ })
166
+ })
@@ -58,7 +58,7 @@ describe('no-repeat-on-timeout wiring', () => {
58
58
  })
59
59
 
60
60
  it('onPermissionRequest short-circuits a recent-timeout duplicate before posting a card', () => {
61
- const fn = slice(GATEWAY, 'onPermissionRequest(', 4000)
61
+ const fn = slice(GATEWAY, 'onPermissionRequest(', 6000)
62
62
  const dupIdx = fn.indexOf('isRecentTimeoutDuplicate(')
63
63
  const cardIdx = fn.indexOf('pendingPermissions.set(requestId')
64
64
  expect(dupIdx).toBeGreaterThan(-1)