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
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Source-text wiring pins for the approval-card re-arm feature (#2861 / #2859).
3
+ *
4
+ * gateway.ts and bridge.ts have top-level side effects and aren't
5
+ * unit-importable; the decision logic is unit-tested in permission-rearm.test.ts
6
+ * and permission-ledger.test.ts. These pins lock the wiring that connects those
7
+ * primitives so the two failure modes can't silently regress:
8
+ * R1 — gateway restart orphans pending approvals (boot-sweep stripped them).
9
+ * R2 — bridge dropped permission requests while the gateway IPC was down.
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import { readFileSync } from 'node:fs'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { dirname, resolve } from 'node:path'
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url))
18
+ const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
19
+ const GATEWAY = read('gateway/gateway.ts')
20
+ const BRIDGE = read('bridge/bridge.ts')
21
+ const IPC_CLIENT = read('bridge/ipc-client.ts')
22
+
23
+ function slice(src: string, header: string, span = 2400): string {
24
+ const start = src.indexOf(header)
25
+ expect(start, `expected to find ${header}`).toBeGreaterThan(-1)
26
+ return src.slice(start, start + span)
27
+ }
28
+
29
+ // ─── Bridge: outstanding-request ledger (R2) ──────────────────────────────
30
+
31
+ describe('bridge ledger wiring', () => {
32
+ it('imports and instantiates the outstanding-permission ledger', () => {
33
+ expect(BRIDGE).toMatch(/from '\.\/permission-ledger\.js'/)
34
+ expect(BRIDGE).toMatch(/createOutstandingPermissionLedger\(\)/)
35
+ })
36
+
37
+ it('records every permission_request in the ledger BEFORE the connect check', () => {
38
+ const handler = slice(BRIDGE, "method: z.literal('notifications/claude/channel/permission_request')", 2600)
39
+ const addIdx = handler.indexOf('outstandingPermissions.add(')
40
+ const connIdx = handler.indexOf('!ipc.isConnected()')
41
+ expect(addIdx, 'ledger.add must be present in the handler').toBeGreaterThan(-1)
42
+ expect(connIdx, 'connect check must be present').toBeGreaterThan(-1)
43
+ expect(addIdx, 'ledger.add must precede the connect check so a disconnected request is retained')
44
+ .toBeLessThan(connIdx)
45
+ })
46
+
47
+ it('the early-return no longer DROPS unconditionally — it buffers on reconnect', () => {
48
+ const handler = slice(BRIDGE, "method: z.literal('notifications/claude/channel/permission_request')", 2600)
49
+ // The old drop path was a bare `if (!ipc || !ipc.isConnected()) { log; return }`
50
+ // with no ledger/buffer. Pin the new buffer semantics instead.
51
+ expect(handler).toMatch(/buffered.*re-send on reconnect/i)
52
+ // Structural anti-regression: the legacy standalone drop message must only
53
+ // appear behind the kill-switch fallback, never as the sole behavior.
54
+ expect(handler).toMatch(/permissionRearmEnabled/)
55
+ })
56
+
57
+ it('deletes from the ledger when a verdict is delivered (onPermission)', () => {
58
+ const onPerm = slice(BRIDGE, 'function onPermission(msg: PermissionEvent)', 900)
59
+ expect(onPerm).toMatch(/outstandingPermissions\.delete\(msg\.requestId\)/)
60
+ })
61
+
62
+ it('flushes outstanding requests on every IPC (re)connect', () => {
63
+ expect(BRIDGE).toMatch(/function flushOutstandingPermissionRequests/)
64
+ expect(BRIDGE).toMatch(/onConnect:\s*flushOutstandingPermissionRequests/)
65
+ const flush = slice(BRIDGE, 'function flushOutstandingPermissionRequests', 900)
66
+ expect(flush).toMatch(/outstandingPermissions\.all\(\)/)
67
+ expect(flush).toMatch(/sendPermissionRequest/)
68
+ })
69
+
70
+ it('honours the SWITCHROOM_PERMISSION_REARM kill switch', () => {
71
+ expect(BRIDGE).toMatch(/SWITCHROOM_PERMISSION_REARM\s*!==\s*'0'/)
72
+ })
73
+ })
74
+
75
+ // ─── IPC client: onConnect hook ───────────────────────────────────────────
76
+
77
+ describe('ipc-client onConnect hook', () => {
78
+ it('exposes an onConnect option', () => {
79
+ expect(IPC_CLIENT).toMatch(/onConnect\?:\s*\(\)\s*=>\s*void/)
80
+ })
81
+ it('invokes onConnect from the socket open() handler (after register)', () => {
82
+ const open = slice(IPC_CLIENT, 'open(sock)', 900)
83
+ const regIdx = open.indexOf('sendRegister()')
84
+ const connIdx = open.indexOf('onConnect(')
85
+ expect(connIdx, 'open() must call onConnect').toBeGreaterThan(-1)
86
+ expect(regIdx).toBeLessThan(connIdx)
87
+ })
88
+ })
89
+
90
+ // ─── Gateway: dedupe + re-arm (R1) ────────────────────────────────────────
91
+
92
+ describe('gateway re-arm wiring', () => {
93
+ it('imports the pure re-arm helpers', () => {
94
+ expect(GATEWAY).toMatch(/from '\.\/permission-rearm\.js'/)
95
+ expect(GATEWAY).toMatch(/classifyPermissionRequest/)
96
+ expect(GATEWAY).toMatch(/computeBootSweepStripTargets/)
97
+ })
98
+
99
+ it('onPermissionRequest dedupes / re-arms before posting a card', () => {
100
+ const fn = slice(GATEWAY, 'onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward)', 5200)
101
+ expect(fn).toMatch(/isPermissionRearmEnabled\(\)/)
102
+ expect(fn).toMatch(/classifyPermissionRequest/)
103
+ // duplicate → return (no second card); rearm → restore from store.
104
+ expect(fn).toMatch(/disposition === 'duplicate'/)
105
+ expect(fn).toMatch(/disposition === 'rearm'/)
106
+ expect(fn).toMatch(/rearmPermissionFromStore\(/)
107
+ // The dedup/re-arm block must sit before the card-post (formatPermissionCardBody).
108
+ const rearmIdx = fn.indexOf('classifyPermissionRequest')
109
+ const postIdx = fn.indexOf('formatPermissionCardBody')
110
+ expect(postIdx).toBeGreaterThan(-1)
111
+ expect(rearmIdx).toBeLessThan(postIdx)
112
+ })
113
+
114
+ it('re-arm preserves the ORIGINAL startedAt (TTL clock, not Date.now())', () => {
115
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
116
+ // startedAt is derived from the persisted entries, never re-stamped.
117
+ expect(fn).toMatch(/c\.startedAt/)
118
+ expect(fn).toMatch(/startedAt,/)
119
+ expect(fn).not.toMatch(/startedAt:\s*Date\.now\(\)/)
120
+ expect(fn).toMatch(/pendingPermissions\.set\(requestId/)
121
+ })
122
+
123
+ it('re-arm re-attaches the keyboard on the EXISTING message(s) via editMessageText', () => {
124
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
125
+ expect(fn).toMatch(/buildPermissionActionRow\(requestId/)
126
+ expect(fn).toMatch(/editMessageText\(/)
127
+ // Re-arm edits existing messages; it must NOT post a brand-new card.
128
+ expect(fn).not.toMatch(/sendRichMessage/)
129
+ })
130
+
131
+ it('never auto-answers — no dispatchPermissionVerdict inside re-arm (no-self-escalation)', () => {
132
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
133
+ expect(fn).not.toMatch(/dispatchPermissionVerdict/)
134
+ })
135
+ })
136
+
137
+ // ─── Gateway: grace-period boot-sweep (R1) ────────────────────────────────
138
+
139
+ describe('gateway boot-sweep grace period', () => {
140
+ it('defers the strip when re-arm is enabled, keeps immediate strip as the kill-switch path', () => {
141
+ const sweep = slice(GATEWAY, 'Boot-sweep for permission cards from a prior gateway session', 4200)
142
+ expect(sweep).toMatch(/if \(!isPermissionRearmEnabled\(\)\)/)
143
+ // legacy immediate path still strips everything + clears the store
144
+ expect(sweep).toMatch(/permCardStore\.clear\(\)/)
145
+ // grace path: setTimeout + grace-ms knob + live-pending filter
146
+ expect(sweep).toMatch(/setTimeout\(/)
147
+ expect(sweep).toMatch(/permissionRearmGraceMs\(\)/)
148
+ // Preserve anything currently live in pendingPermissions — covers both
149
+ // re-armed AND fresh-during-grace cards (reviewer-caught blocker fix).
150
+ expect(sweep).toMatch(/new Set\(pendingPermissions\.keys\(\)\)/)
151
+ expect(sweep).toMatch(/computeBootSweepStripTargets\(remaining, liveRequestIds\)/)
152
+ })
153
+
154
+ it('the grace path removes only stripped request_ids, not the whole store', () => {
155
+ const sweep = slice(GATEWAY, 'Boot-sweep for permission cards from a prior gateway session', 4200)
156
+ // Per-request removal (preserves re-armed live entries) inside the deferred block.
157
+ expect(sweep).toMatch(/distinctRequestIds\(toStrip\)/)
158
+ expect(sweep).toMatch(/permCardStore\.remove\(id\)/)
159
+ })
160
+ })
161
+
162
+ // ─── Gateway: re-arm re-holds the #2840 inbound gate ──────────────────────
163
+
164
+ describe('re-armed pending card re-holds the inbound gate (#2840)', () => {
165
+ it('turnInFlightForGate counts pending approvals', () => {
166
+ const gate = slice(GATEWAY, 'function turnInFlightForGate()', 1200)
167
+ expect(gate).toMatch(/pendingPermissions\.size > 0/)
168
+ })
169
+ it('re-arm restores a pendingPermissions entry → gate held automatically', () => {
170
+ // The gate reads pendingPermissions.size; rearmPermissionFromStore sets an
171
+ // entry, so a re-armed card holds the gate with no extra wiring.
172
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
173
+ expect(fn).toMatch(/pendingPermissions\.set\(requestId/)
174
+ })
175
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Unit tests for the pure permission-card re-arm helpers (#2861 / #2859).
3
+ *
4
+ * These cover the discriminable decision logic extracted from gateway.ts
5
+ * (which has top-level side effects and isn't unit-importable):
6
+ * - classifyPermissionRequest — duplicate / rearm / fresh
7
+ * - computeBootSweepStripTargets — grace-period strip filtering
8
+ * - isPermissionRearmEnabled / permissionRearmGraceMs — kill switch + knob
9
+ * - distinctRequestIds
10
+ *
11
+ * The gateway/bridge WIRING that consumes these is pinned separately in
12
+ * permission-rearm-wiring.test.ts.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import {
17
+ classifyPermissionRequest,
18
+ computeBootSweepStripTargets,
19
+ isPermissionRearmEnabled,
20
+ permissionRearmGraceMs,
21
+ distinctRequestIds,
22
+ PERMISSION_REARM_GRACE_MS,
23
+ } from '../gateway/permission-rearm.js'
24
+ import type { PersistedPermCard } from '../gateway/permission-card-store.js'
25
+
26
+ function card(requestId: string, messageId: number): PersistedPermCard {
27
+ return {
28
+ requestId,
29
+ chatId: '-100123',
30
+ messageId,
31
+ startedAt: 1_000,
32
+ toolName: 'mcp__brevo__post',
33
+ cardText: 'Marko wants to post to Brevo',
34
+ }
35
+ }
36
+
37
+ describe('classifyPermissionRequest', () => {
38
+ it('already-live request → duplicate (ignore, no second card)', () => {
39
+ expect(classifyPermissionRequest({ hasPending: true, persistedCount: 0 })).toBe('duplicate')
40
+ // hasPending wins even if the store also has it
41
+ expect(classifyPermissionRequest({ hasPending: true, persistedCount: 2 })).toBe('duplicate')
42
+ })
43
+
44
+ it('gone from memory but persisted → rearm', () => {
45
+ expect(classifyPermissionRequest({ hasPending: false, persistedCount: 1 })).toBe('rearm')
46
+ expect(classifyPermissionRequest({ hasPending: false, persistedCount: 3 })).toBe('rearm')
47
+ })
48
+
49
+ it('unknown request → fresh (post a new card)', () => {
50
+ expect(classifyPermissionRequest({ hasPending: false, persistedCount: 0 })).toBe('fresh')
51
+ })
52
+ })
53
+
54
+ describe('computeBootSweepStripTargets', () => {
55
+ it('strips only entries whose request_id is NOT currently live', () => {
56
+ const stale = [card('aaaaa', 10), card('bbbbb', 11), card('ccccc', 12)]
57
+ const live = new Set(['bbbbb']) // bbbbb re-armed / still pending
58
+ const targets = computeBootSweepStripTargets(stale, live)
59
+ expect(targets.map(t => t.requestId)).toEqual(['aaaaa', 'ccccc'])
60
+ })
61
+
62
+ it('a re-armed (live) entry survives the deadline (empty strip)', () => {
63
+ const stale = [card('aaaaa', 10)]
64
+ const live = new Set(['aaaaa'])
65
+ expect(computeBootSweepStripTargets(stale, live)).toEqual([])
66
+ })
67
+
68
+ it('a dead-session entry (no live pending) is stripped at the deadline', () => {
69
+ const stale = [card('aaaaa', 10)]
70
+ const live = new Set<string>()
71
+ expect(computeBootSweepStripTargets(stale, live).map(t => t.requestId)).toEqual(['aaaaa'])
72
+ })
73
+
74
+ it('a FRESH card born during the grace window (live but not in the boot set) survives', () => {
75
+ // Regression for the reviewer-caught blocker: a new approval posted after
76
+ // boot writes its own persisted row, so it appears in `remaining` at the
77
+ // deadline. It's live in pendingPermissions, so it must NOT be stripped —
78
+ // otherwise a legitimately-suspended turn gets re-wedged.
79
+ const remaining = [card('dead0', 10), card('fresh', 20)]
80
+ const live = new Set(['fresh']) // 'fresh' posted during grace, still pending
81
+ const targets = computeBootSweepStripTargets(remaining, live)
82
+ expect(targets.map(t => t.requestId)).toEqual(['dead0'])
83
+ })
84
+
85
+ it('keeps ALL fan-out messages for a dead-session request', () => {
86
+ // Same request_id, two operator surfaces → two persisted rows.
87
+ const stale = [card('aaaaa', 10), card('aaaaa', 20)]
88
+ const targets = computeBootSweepStripTargets(stale, new Set())
89
+ expect(targets).toHaveLength(2)
90
+ expect(targets.map(t => t.messageId).sort()).toEqual([10, 20])
91
+ })
92
+ })
93
+
94
+ describe('distinctRequestIds', () => {
95
+ it('dedupes request_ids across fan-out rows', () => {
96
+ const cards = [card('aaaaa', 10), card('aaaaa', 20), card('bbbbb', 30)]
97
+ expect(distinctRequestIds(cards).sort()).toEqual(['aaaaa', 'bbbbb'])
98
+ })
99
+ })
100
+
101
+ describe('isPermissionRearmEnabled (kill switch)', () => {
102
+ it('enabled by default (unset)', () => {
103
+ expect(isPermissionRearmEnabled({})).toBe(true)
104
+ })
105
+ it('enabled for any value other than "0"', () => {
106
+ expect(isPermissionRearmEnabled({ SWITCHROOM_PERMISSION_REARM: '1' })).toBe(true)
107
+ expect(isPermissionRearmEnabled({ SWITCHROOM_PERMISSION_REARM: 'on' })).toBe(true)
108
+ })
109
+ it('disabled only for the exact "0" kill value', () => {
110
+ expect(isPermissionRearmEnabled({ SWITCHROOM_PERMISSION_REARM: '0' })).toBe(false)
111
+ })
112
+ })
113
+
114
+ describe('permissionRearmGraceMs', () => {
115
+ it('defaults to PERMISSION_REARM_GRACE_MS when unset', () => {
116
+ expect(permissionRearmGraceMs({})).toBe(PERMISSION_REARM_GRACE_MS)
117
+ })
118
+ it('honours a finite non-negative override (test collapse)', () => {
119
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: '0' })).toBe(0)
120
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: '250' })).toBe(250)
121
+ })
122
+ it('falls back to the default on garbage / negative', () => {
123
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: 'nope' })).toBe(PERMISSION_REARM_GRACE_MS)
124
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: '-5' })).toBe(PERMISSION_REARM_GRACE_MS)
125
+ })
126
+ })
@@ -696,12 +696,11 @@ import {
696
696
  } from "../quota-watch.js";
697
697
 
698
698
  describe("resolveQuotaWatchTuning", () => {
699
- it("defaults: stale gate 60min, late-recovery 6h, dedup on, probe-fail send off", () => {
699
+ it("defaults: stale gate 60min, late-recovery 6h, dedup on", () => {
700
700
  const t = resolveQuotaWatchTuning({});
701
701
  expect(t.maxStaleMs).toBe(DEFAULT_QUOTA_WATCH_MAX_STALE_MS);
702
702
  expect(t.lateRecoveryMs).toBe(DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS);
703
703
  expect(t.fleetDedup).toBe(true);
704
- expect(t.sendOnProbeFail).toBe(false);
705
704
  });
706
705
 
707
706
  it("each knob is individually kill-switchable", () => {
@@ -709,12 +708,10 @@ describe("resolveQuotaWatchTuning", () => {
709
708
  SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS: "0",
710
709
  SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS: "0",
711
710
  SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP: "0",
712
- SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL: "1",
713
711
  });
714
712
  expect(t.maxStaleMs).toBe(0);
715
713
  expect(t.lateRecoveryMs).toBe(0);
716
714
  expect(t.fleetDedup).toBe(false);
717
- expect(t.sendOnProbeFail).toBe(true);
718
715
  });
719
716
 
720
717
  it("garbage numeric values fall back to defaults", () => {
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ decideInboundDelivery,
5
+ reserveInboundDelivery,
6
+ } from '../gateway/inbound-delivery-gate.js'
7
+
8
+ /**
9
+ * #2917 — rapid-fire DM: replies emitted late & out of order.
10
+ *
11
+ * Root cause: the #1556 delivery gate is documented as a LIVE read
12
+ * ("evaluated at delivery time — not a receipt-time snapshot"), but on the
13
+ * concurrent `handleInbound` path the gate decision and the busy-mark that
14
+ * records "a turn is in flight for this chat" were separated by an `await`
15
+ * (attachment download, composer-clear). Two same-chat inbounds each read
16
+ * "idle" during the other's async lead-in, both passed the gate, and both
17
+ * were delivered to the bridge — so the replies came back reordered.
18
+ *
19
+ * The fix couples the deliver decision with a synchronous RESERVE
20
+ * (`reserveInboundDelivery`): a fresh-turn deliver reserves the chat's busy
21
+ * key BEFORE any await, so a concurrent same-chat inbound observes the
22
+ * reservation on the live gate and buffers behind it — restoring per-chat
23
+ * FIFO without dropping anything (a buffered inbound drains on turn-complete
24
+ * / idle exactly as before).
25
+ */
26
+ describe('reserveInboundDelivery — fresh-turn reservation flag', () => {
27
+ it('reserves a fresh-turn deliver (idle, non-steering, non-interrupt)', () => {
28
+ const r = reserveInboundDelivery({ turnInFlight: false, isSteering: false, isInterrupt: false })
29
+ expect(r.decision).toBe('deliver')
30
+ expect(r.reserve).toBe(true)
31
+ })
32
+
33
+ it('does NOT reserve a buffered (mid-turn) inbound', () => {
34
+ const r = reserveInboundDelivery({ turnInFlight: true, isSteering: false, isInterrupt: false })
35
+ expect(r.decision).toBe('buffer-until-idle')
36
+ expect(r.reserve).toBe(false)
37
+ })
38
+
39
+ it('does NOT reserve a steering deliver (amends the running turn)', () => {
40
+ const r = reserveInboundDelivery({ turnInFlight: true, isSteering: true, isInterrupt: false })
41
+ expect(r.decision).toBe('deliver')
42
+ expect(r.reserve).toBe(false)
43
+ })
44
+
45
+ it('does NOT reserve an interrupt deliver (amends the running turn)', () => {
46
+ const r = reserveInboundDelivery({ turnInFlight: true, isSteering: false, isInterrupt: true })
47
+ expect(r.decision).toBe('deliver')
48
+ expect(r.reserve).toBe(false)
49
+ })
50
+
51
+ it('decision stays byte-identical to decideInboundDelivery across the matrix', () => {
52
+ for (const turnInFlight of [false, true]) {
53
+ for (const isSteering of [false, true]) {
54
+ for (const isInterrupt of [false, true]) {
55
+ const input = { turnInFlight, isSteering, isInterrupt }
56
+ expect(reserveInboundDelivery(input).decision).toBe(decideInboundDelivery(input))
57
+ }
58
+ }
59
+ }
60
+ })
61
+ })
62
+
63
+ /**
64
+ * A minimal model of the gateway's concurrent same-chat delivery path. Each
65
+ * inbound handler:
66
+ * 1. reads the LIVE gate off the shared busy set,
67
+ * 2. yields at an `await` (the composer-clear / attachment lead-in),
68
+ * 3. on a reserving deliver, marks the chat busy and appends to the wire.
69
+ *
70
+ * This reproduces the ordering race deterministically: two same-chat handlers
71
+ * whose async lead-ins overlap. The `syncReserve` flag toggles the fix — when
72
+ * off (legacy: mark busy only AFTER the await), both handlers deliver and the
73
+ * wire order is decided by the await race; when on (#2917: reserve BEFORE the
74
+ * await), the second handler sees the reservation and buffers, preserving FIFO.
75
+ */
76
+ interface WireResult {
77
+ delivered: string[]
78
+ buffered: string[]
79
+ }
80
+
81
+ async function runTwoConcurrentInbounds(opts: {
82
+ syncReserve: boolean
83
+ /** ms the first handler's lead-in await takes vs the second's. First is
84
+ * SLOWER so, without the fix, the second overtakes it on the wire. */
85
+ firstLeadInMs: number
86
+ secondLeadInMs: number
87
+ }): Promise<WireResult> {
88
+ const busy = new Set<string>()
89
+ const key = 'chatDM:_'
90
+ const delivered: string[] = []
91
+ const buffered: string[] = []
92
+
93
+ const handle = async (id: string, leadInMs: number): Promise<void> => {
94
+ // Step 1 — live gate read.
95
+ const gate = reserveInboundDelivery({
96
+ turnInFlight: busy.size > 0,
97
+ isSteering: false,
98
+ isInterrupt: false,
99
+ })
100
+ if (gate.decision === 'buffer-until-idle') {
101
+ buffered.push(id)
102
+ return
103
+ }
104
+ // Step 2 — the fix reserves synchronously BEFORE the await.
105
+ let reserved = false
106
+ if (opts.syncReserve && gate.reserve) {
107
+ busy.add(key)
108
+ reserved = true
109
+ }
110
+ // Step 3 — the async lead-in (composer-clear / attachment download).
111
+ await new Promise((r) => setTimeout(r, leadInMs))
112
+ // Step 4 — legacy marks busy only now, after the await.
113
+ if (!reserved) busy.add(key)
114
+ delivered.push(id)
115
+ }
116
+
117
+ await Promise.all([
118
+ handle('msg1', opts.firstLeadInMs),
119
+ handle('msg2', opts.secondLeadInMs),
120
+ ])
121
+ return { delivered, buffered }
122
+ }
123
+
124
+ describe('rapid-fire same-chat delivery ordering (#2917)', () => {
125
+ it('LEGACY (mark-after-await): the faster second inbound overtakes the first — reorder', async () => {
126
+ // msg1's lead-in is slower, so with the busy-mark deferred until after the
127
+ // await BOTH pass the idle gate and BOTH deliver, with msg2 landing first.
128
+ const { delivered, buffered } = await runTwoConcurrentInbounds({
129
+ syncReserve: false,
130
+ firstLeadInMs: 20,
131
+ secondLeadInMs: 1,
132
+ })
133
+ expect(buffered).toEqual([]) // nothing buffered — the bug
134
+ expect(delivered).toEqual(['msg2', 'msg1']) // out of order
135
+ })
136
+
137
+ it('FIX (sync reserve): the second inbound sees the reservation and buffers — FIFO preserved', async () => {
138
+ const { delivered, buffered } = await runTwoConcurrentInbounds({
139
+ syncReserve: true,
140
+ firstLeadInMs: 20,
141
+ secondLeadInMs: 1,
142
+ })
143
+ // msg1 reserved synchronously; msg2's live gate sees busy → buffers.
144
+ expect(delivered).toEqual(['msg1'])
145
+ expect(buffered).toEqual(['msg2'])
146
+ // msg2 is not lost — it is held for the turn-complete / idle drain, which
147
+ // re-delivers it in order after msg1's turn ends.
148
+ })
149
+ })
@@ -0,0 +1,136 @@
1
+ // Cross-check the new markdown->IR parser (render/parse.ts) against the
2
+ // existing adversarial torture-fixture set (formatting-torture-set.ts).
3
+ //
4
+ // This does NOT exercise the renderer or chunker (out of scope for the
5
+ // parser+IR increment) — it feeds each fixture's raw `input` straight into
6
+ // `parse()` and walks the resulting IR tree collecting a flat entity list,
7
+ // then asserts every entity the fixture's `expect` block calls for is
8
+ // present in that list. This is a structural cross-check independent of the
9
+ // existing gateway-pipeline oracle: it proves the new parser captures the
10
+ // same intent the current hand-rolled pipeline is required to preserve.
11
+ import { describe, it, expect } from 'vitest'
12
+ import { parse } from '../../render/parse.js'
13
+ import type { Block, Inline } from '../../render/ir.js'
14
+ import { TORTURE_SET, type ExpectEntity } from '../formatting-torture-set.js'
15
+ import type { EntityType } from '../rich-markdown-oracle.js'
16
+
17
+ interface FlatEntity {
18
+ type: EntityType
19
+ text: string
20
+ url?: string
21
+ lang?: string
22
+ }
23
+
24
+ function textOf(nodes: Inline[]): string {
25
+ return nodes
26
+ .map((n) => {
27
+ switch (n.type) {
28
+ case 'plain':
29
+ case 'code':
30
+ return n.text
31
+ default:
32
+ return textOf(n.children)
33
+ }
34
+ })
35
+ .join('')
36
+ }
37
+
38
+ function walkInline(node: Inline, out: FlatEntity[]) {
39
+ switch (node.type) {
40
+ case 'bold':
41
+ out.push({ type: 'bold', text: textOf(node.children) })
42
+ node.children.forEach((c) => walkInline(c, out))
43
+ break
44
+ case 'italic':
45
+ out.push({ type: 'italic', text: textOf(node.children) })
46
+ node.children.forEach((c) => walkInline(c, out))
47
+ break
48
+ case 'strike':
49
+ out.push({ type: 'strikethrough', text: textOf(node.children) })
50
+ node.children.forEach((c) => walkInline(c, out))
51
+ break
52
+ case 'code':
53
+ out.push({ type: 'code', text: node.text })
54
+ break
55
+ case 'link':
56
+ out.push({ type: 'link', text: textOf(node.children), url: node.href })
57
+ node.children.forEach((c) => walkInline(c, out))
58
+ break
59
+ case 'plain':
60
+ break
61
+ case 'underline':
62
+ case 'spoiler':
63
+ case 'highlight':
64
+ // No dedicated oracle EntityType for these Bot API 10.1 constructs yet;
65
+ // recurse so any nested bold/italic/code/link inside them is still
66
+ // collected for the cross-check.
67
+ node.children.forEach((c) => walkInline(c, out))
68
+ break
69
+ }
70
+ }
71
+
72
+ function walkBlock(node: Block, out: FlatEntity[]) {
73
+ switch (node.type) {
74
+ case 'paragraph':
75
+ case 'heading':
76
+ node.children.forEach((c) => walkInline(c, out))
77
+ break
78
+ case 'blockquote':
79
+ node.children.forEach((c) => walkBlock(c, out))
80
+ break
81
+ case 'code-block':
82
+ out.push({ type: 'pre', text: node.text, lang: node.language ?? undefined })
83
+ break
84
+ case 'list':
85
+ for (const item of node.items) item.children.forEach((c) => walkBlock(c, out))
86
+ break
87
+ case 'table':
88
+ for (const row of [node.header, ...node.rows]) {
89
+ for (const cell of row.cells) cell.children.forEach((c) => walkInline(c, out))
90
+ }
91
+ break
92
+ case 'thematic-break':
93
+ break
94
+ }
95
+ }
96
+
97
+ function flatten(input: string): FlatEntity[] {
98
+ const doc = parse(input)
99
+ const out: FlatEntity[] = []
100
+ doc.blocks.forEach((b) => walkBlock(b, out))
101
+ return out
102
+ }
103
+
104
+ function matches(found: FlatEntity[], want: ExpectEntity): boolean {
105
+ return found.some(
106
+ (f) =>
107
+ f.type === want.type &&
108
+ f.text === want.text &&
109
+ (want.url === undefined || f.url === want.url) &&
110
+ (want.lang === undefined || f.lang === want.lang),
111
+ )
112
+ }
113
+
114
+ describe('parse: torture-fixture cross-check', () => {
115
+ for (const fixture of TORTURE_SET) {
116
+ if (fixture.expect.length === 0) {
117
+ // No entity assertions for this fixture — just prove parse() doesn't
118
+ // throw and returns at least one block (structure-only fixtures).
119
+ it(`${fixture.name}: parses without throwing`, () => {
120
+ const doc = parse(fixture.input)
121
+ expect(doc.blocks.length).toBeGreaterThan(0)
122
+ })
123
+ continue
124
+ }
125
+
126
+ it(`${fixture.name}: IR captures every expected entity`, () => {
127
+ const found = flatten(fixture.input)
128
+ for (const want of fixture.expect) {
129
+ expect(
130
+ matches(found, want),
131
+ `expected a ${want.type} entity with text ${JSON.stringify(want.text)} in fixture "${fixture.name}"; found: ${JSON.stringify(found)}`,
132
+ ).toBe(true)
133
+ }
134
+ })
135
+ }
136
+ })