switchroom 0.15.45 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Emission-authority façade — structural (source-read) assertions for PR-4a of
3
+ * the message-emission-determinism refactor.
4
+ *
5
+ * PR-4a introduces a kill-switched, behaviourally-IDENTICAL no-op seam: a
6
+ * per-foreground-turn emission-authority façade (`emission-authority.ts`) that
7
+ * the foreground-lane card/ping emission call sites route through. In PR-4a
8
+ * EVERY façade method is a thin delegate — no decision logic moves in (that is
9
+ * PR-4b-4e). These tests pin that the seam is wired AND that it is still a
10
+ * no-op:
11
+ *
12
+ * 1. The 6 drain sites + 2 finalize blocks + the over-ping block now reference
13
+ * the façade methods, with the per-site producer args preserved verbatim.
14
+ * 2. The façade's disabled and enabled branches are behaviourally identical in
15
+ * PR-4a (no decision token like `mayOpenActivityCard` has moved into the
16
+ * façade yet — that is PR-4b).
17
+ * 3. `SWITCHROOM_EMISSION_AUTHORITY` parses default-OFF (`=== '1'`).
18
+ * 4. The seam delegates to the existing
19
+ * `drainActivitySummary`/`clearActivitySummary`/`decideOverPing` — the
20
+ * load-bearing literals stay AT THE CALL SITE.
21
+ *
22
+ * Pure source-reads (the gateway IIFE can't be instantiated in-process; the
23
+ * pattern mirrors emission-determinism-wiring.test.ts). NO sqlite import, so
24
+ * this runs cleanly under vitest (the bun/vitest split gotcha).
25
+ */
26
+ import { describe, it, expect } from 'vitest'
27
+ import { readFileSync } from 'node:fs'
28
+ import { resolve } from 'node:path'
29
+
30
+ const facadeSrc = readFileSync(
31
+ resolve(__dirname, '..', 'gateway', 'emission-authority.ts'),
32
+ 'utf-8',
33
+ )
34
+ /**
35
+ * The façade source with comment lines stripped, so "no executable reference to
36
+ * X" assertions don't trip on a legitimate prose mention of a primitive name in
37
+ * a doc-comment (the seam is heavily documented). Drops `//`/`*`/`/**`-style
38
+ * comment lines; keeps real code.
39
+ */
40
+ const facadeCode = facadeSrc
41
+ .split('\n')
42
+ .filter((l) => {
43
+ const t = l.trim()
44
+ return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
45
+ })
46
+ .join('\n')
47
+ const gatewaySrc = readFileSync(
48
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
49
+ 'utf-8',
50
+ )
51
+
52
+ /** Source of a named gateway function up to the next top-level definition. */
53
+ function fnSrc(name: string): string {
54
+ const after = gatewaySrc.split(`function ${name}(`)[1] ?? ''
55
+ return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
56
+ }
57
+
58
+ describe('SWITCHROOM_EMISSION_AUTHORITY kill-switch (default OFF)', () => {
59
+ it('parses with `=== "1"` semantic so an unset env var is OFF (default off)', () => {
60
+ expect(facadeSrc).toMatch(
61
+ /EMISSION_AUTHORITY_ENABLED\s*=\s*process\.env\.SWITCHROOM_EMISSION_AUTHORITY\s*===\s*'1'/,
62
+ )
63
+ })
64
+
65
+ it('is read ONCE at module top, not per-call (a single module-level const)', () => {
66
+ const reads = [...facadeSrc.matchAll(/process\.env\.SWITCHROOM_EMISSION_AUTHORITY/g)]
67
+ expect(reads).toHaveLength(1)
68
+ // The const initialiser sits at module scope (no leading indentation),
69
+ // mirroring the gateway flag region convention.
70
+ expect(facadeSrc).toMatch(/\nexport const EMISSION_AUTHORITY_ENABLED =/)
71
+ })
72
+ })
73
+
74
+ describe('façade is a no-op in PR-4a — both kill-switch branches delegate identically', () => {
75
+ /** Body of a façade method up to the next method/`}` at method indentation. */
76
+ function methodBody(name: string): string {
77
+ const after = facadeSrc.split(`${name}(`)[1] ?? ''
78
+ // Stop at the next method declaration (two-space indent + identifier + `(`)
79
+ // or the class close.
80
+ return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
81
+ }
82
+
83
+ // PR-4b: `openOrEditCard` is DELIBERATELY dropped from this no-op loop — its
84
+ // enabled branch now GATES `apply()` behind the OPEN verdict (no longer an
85
+ // identical no-op). PR-4c: `claimOrDowngradePing` is ALSO dropped — its
86
+ // enabled branch now COMPUTES the over-ping decision via `decideOverPing` and
87
+ // hands it to `applyDecision` (no longer an identical no-op). Both get
88
+ // dedicated shape tests below. The two remaining façade methods stay identical
89
+ // no-ops in both branches.
90
+ for (const m of [
91
+ 'finalizeCard',
92
+ 'markSubstantiveFinalDelivered',
93
+ ]) {
94
+ it(`${m} runs the SAME delegate in the enabled and disabled branch (identical no-op)`, () => {
95
+ const body = methodBody(m)
96
+ // The enabled branch is gated on the flag and, in PR-4a, just runs apply()
97
+ // exactly like the disabled fall-through. Assert BOTH an
98
+ // `if (EMISSION_AUTHORITY_ENABLED)` branch and a bare `apply()` exist, and
99
+ // that NO decision token has leaked into these façade methods.
100
+ expect(body).toMatch(/if\s*\(EMISSION_AUTHORITY_ENABLED\)/)
101
+ const applyCalls = [...body.matchAll(/apply\(\)/g)]
102
+ // One in the enabled branch, one in the disabled fall-through.
103
+ expect(applyCalls.length).toBeGreaterThanOrEqual(2)
104
+ })
105
+ }
106
+
107
+ it('no decision token leaked into the remaining no-op façade methods (finalize / markSubstantive)', () => {
108
+ // The two remaining no-op methods (finalize / markSubstantive) move NO
109
+ // decision logic in: both kill-switch branches run the same `apply()`. Only
110
+ // `openOrEditCard` (PR-4b) and `claimOrDowngradePing` (PR-4c) consult a
111
+ // decision — each does so ONLY inside its EMISSION_AUTHORITY_ENABLED branch
112
+ // (asserted in the dedicated describes below).
113
+ function methodBody(name: string): string {
114
+ const after = facadeSrc.split(`${name}(`)[1] ?? ''
115
+ const body = after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
116
+ // Strip comment lines: the body can spill into the NEXT method's leading
117
+ // doc-comment (which legitimately names decideOverPing in prose). Only
118
+ // executable references count as a "leaked decision token".
119
+ return body
120
+ .split('\n')
121
+ .filter((l) => {
122
+ const t = l.trim()
123
+ return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
124
+ })
125
+ .join('\n')
126
+ }
127
+ for (const m of ['finalizeCard', 'markSubstantiveFinalDelivered']) {
128
+ const body = methodBody(m)
129
+ expect(body).not.toMatch(/computeFeedOpenVerdict\(/)
130
+ expect(body).not.toMatch(/decideOverPing\(/)
131
+ }
132
+ })
133
+
134
+ it('mayDrain is a PURE READ — returns activityInFlight == null, acquires no lock', () => {
135
+ const after = facadeSrc.split('mayDrain(')[1] ?? ''
136
+ const body = after.split('\n}')[0] ?? after
137
+ expect(body).toMatch(/activityInFlight == null/)
138
+ // PR-4d invariant: mayDrain must not acquire chatLock. No lock token in body.
139
+ expect(body).not.toMatch(/chatLock|acquire|lock\(/i)
140
+ })
141
+
142
+ it('the PR-4d deadlock invariant is documented verbatim-ish in the module header', () => {
143
+ expect(facadeSrc).toMatch(/mayDrain` is a (pure|PURE) read/i)
144
+ expect(facadeSrc).toMatch(/must NOT acquire `chatLock`/)
145
+ })
146
+ })
147
+
148
+ describe('openOrEditCard — PR-4b OPEN-gate moves into the façade (inverts the 4a no-op proof for this method)', () => {
149
+ /** Body of `openOrEditCard` up to the next method declaration / class close. */
150
+ function openOrEditBody(): string {
151
+ const after = facadeSrc.split('openOrEditCard(')[1] ?? ''
152
+ return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
153
+ }
154
+
155
+ it('the façade NOW imports + consults the open verdict (computeFeedOpenVerdict) — inverted from PR-4a', () => {
156
+ // PR-4a asserted NO decision token had moved in. PR-4b INVERTS that for the
157
+ // OPEN gate: the verdict helper is imported and CALLED in the façade.
158
+ expect(facadeSrc).toMatch(/import\s*\{[^}]*computeFeedOpenVerdict[^}]*\}\s*from\s*'\.\/feed-open-gate\.js'/)
159
+ expect(facadeCode).toMatch(/computeFeedOpenVerdict\(/)
160
+ })
161
+
162
+ it('the verdict is consulted ONLY inside the EMISSION_AUTHORITY_ENABLED branch (disabled branch stays a pure pass-through)', () => {
163
+ const body = openOrEditBody()
164
+ const flagIdx = body.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
165
+ const verdictIdx = body.indexOf('computeFeedOpenVerdict(')
166
+ expect(flagIdx).toBeGreaterThan(-1)
167
+ expect(verdictIdx).toBeGreaterThan(-1)
168
+ // The verdict call sits AFTER the enabled-branch guard opens.
169
+ expect(verdictIdx).toBeGreaterThan(flagIdx)
170
+ // The DISABLED fall-through (after the enabled branch returns) is a bare
171
+ // apply() with no verdict: the LAST statement of the method is `apply()` and
172
+ // there is no second computeFeedOpenVerdict call outside the enabled branch.
173
+ const verdictCalls = [...body.matchAll(/computeFeedOpenVerdict\(/g)]
174
+ expect(verdictCalls).toHaveLength(1)
175
+ })
176
+
177
+ it('enabled branch GUARDS apply() behind the verdict; disabled branch calls apply() UNCONDITIONALLY', () => {
178
+ const body = openOrEditBody()
179
+ // Enabled branch: a refusal returns BEFORE apply() when the OPEN is refused
180
+ // (the relocated `break`). The guard keys on the verdict's isOpen/mayOpen.
181
+ expect(body).toMatch(/if\s*\(!isOpen\s*&&\s*!mayOpen\)\s*return/)
182
+ // Both isOpen and mayOpen come from the destructured verdict.
183
+ expect(body).toMatch(/const\s*\{\s*isOpen,\s*mayOpen\s*\}\s*=\s*computeFeedOpenVerdict\(/)
184
+ // Disabled branch: after the enabled branch returns, the method falls
185
+ // through to an unconditional `apply()` then the method `}` — 4a behaviour
186
+ // preserved when the flag is OFF (no verdict consulted on this path).
187
+ expect(body).toMatch(/return\s*\n\s*\}\s*\n\s*apply\(\)\s*\n\s*\}/)
188
+ })
189
+ })
190
+
191
+ // ─── PR-4c block — kept SEPARATED from (adjacent to, not interleaved with) the
192
+ // PR-4b openOrEditCard describe above, to minimize rebase conflict with 4b's
193
+ // edits to this same file. ────────────────────────────────────────────────────
194
+ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the façade (inverts the 4a no-op proof for this method)', () => {
195
+ /** Body of `claimOrDowngradePing` up to the next method declaration / class close. */
196
+ function pingBody(): string {
197
+ const after = facadeSrc.split('claimOrDowngradePing(')[1] ?? ''
198
+ return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
199
+ }
200
+
201
+ it('the façade NOW imports + consults the over-ping decision (decideOverPing) — inverted from PR-4a', () => {
202
+ // PR-4a asserted NO decision token had moved in. PR-4c INVERTS that for the
203
+ // ping gate: the pure predicate is imported and CALLED in the façade.
204
+ expect(facadeSrc).toMatch(/import\s*\{[^}]*decideOverPing[^}]*\}\s*from\s*'\.\.\/over-ping-safety-net\.js'/)
205
+ expect(facadeCode).toMatch(/decideOverPing\(/)
206
+ })
207
+
208
+ it('the decision is consulted ONLY inside the EMISSION_AUTHORITY_ENABLED branch (disabled branch stays a pure pass-through)', () => {
209
+ const body = pingBody()
210
+ const flagIdx = body.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
211
+ const decideIdx = body.indexOf('decideOverPing(')
212
+ expect(flagIdx).toBeGreaterThan(-1)
213
+ expect(decideIdx).toBeGreaterThan(-1)
214
+ // The decision call sits AFTER the enabled-branch guard opens.
215
+ expect(decideIdx).toBeGreaterThan(flagIdx)
216
+ // Exactly one decideOverPing call in the façade method (the enabled branch);
217
+ // the disabled fall-through delegates via `disabled()` with no decision.
218
+ const decideCalls = [...body.matchAll(/decideOverPing\(/g)]
219
+ expect(decideCalls).toHaveLength(1)
220
+ })
221
+
222
+ it('enabled branch computes-and-hands-back; disabled branch delegates via disabled() with NO decision', () => {
223
+ const body = pingBody()
224
+ // Enabled branch: compute the decision then hand it to applyDecision.
225
+ expect(body).toMatch(/const\s+decision\s*=\s*decideOverPing\(/)
226
+ expect(body).toMatch(/applyDecision\(decision\)/)
227
+ // Disabled branch: after the enabled branch returns, the method falls
228
+ // through to a bare `disabled()` then the method `}` — the disabled path
229
+ // never touches the façade's decision (it computes its own at the call site).
230
+ expect(body).toMatch(/return\s*\n\s*\}\s*\n\s*disabled\(\)\s*\n\s*\}/)
231
+ })
232
+
233
+ it('the façade method is SYNCHRONOUS — no async, no await in the decide→applyDecision chain (atomicity invariant)', () => {
234
+ const body = pingBody()
235
+ // The #2562 atomicity invariant: the decision + the pair-set run in one
236
+ // synchronous block, no await between, so a racing second reply reads a
237
+ // consistent pair. The façade method must therefore not be async / await.
238
+ expect(facadeSrc).not.toMatch(/async\s+claimOrDowngradePing/)
239
+ expect(body).not.toMatch(/\bawait\b/)
240
+ })
241
+
242
+ it('the call-site applyDecision thunk performs the atomic two-adjacent-line pair-set with NO await between', () => {
243
+ // The relocation must NOT split the #2562 pair across the façade boundary:
244
+ // the façade decides; the call-site thunk sets firstPingAt AND
245
+ // firstPingWasSubstantive on two adjacent lines, no await between.
246
+ expect(gatewaySrc).toMatch(
247
+ /turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
248
+ )
249
+ // No await anywhere in the executeReply over-ping block (the decide→apply→
250
+ // pair-set synchronous chain). Bound to the block, BEFORE the Telegraph
251
+ // block below (which legitimately awaits).
252
+ const blockStart = gatewaySrc.indexOf('const applyOverPingDecision')
253
+ const telegraphIdx = gatewaySrc.indexOf('// Telegraph publish (#579)', blockStart)
254
+ const block = gatewaySrc.slice(blockStart, telegraphIdx)
255
+ const blockCode = block
256
+ .split('\n')
257
+ .filter((l) => {
258
+ const t = l.trim()
259
+ return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
260
+ })
261
+ .join('\n')
262
+ expect(blockCode).toMatch(/\.claimOrDowngradePing\(/)
263
+ expect(blockCode).not.toMatch(/\bawait\b/)
264
+ })
265
+
266
+ it('claimOrDowngradePing appears EXACTLY ONCE in the gateway, inside the executeReply window (stream path untouched)', () => {
267
+ // The over-ping net exists ONLY in executeReply. executeStreamReply has no
268
+ // decideOverPing / firstPingAt / wasOverPingSuppressed and never calls
269
+ // claimOrDowngradePing — PR-4c does not touch the stream path.
270
+ const calls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
271
+ expect(calls).toHaveLength(1)
272
+ const callIdx = gatewaySrc.indexOf('.claimOrDowngradePing(')
273
+ const execReplyIdx = gatewaySrc.indexOf('async function executeReply(')
274
+ const execStreamIdx = gatewaySrc.indexOf('async function executeStreamReply(')
275
+ expect(execReplyIdx).toBeGreaterThan(-1)
276
+ expect(execStreamIdx).toBeGreaterThan(execReplyIdx)
277
+ // The single call is inside executeReply (before executeStreamReply starts).
278
+ expect(callIdx).toBeGreaterThan(execReplyIdx)
279
+ expect(callIdx).toBeLessThan(execStreamIdx)
280
+ })
281
+ })
282
+
283
+ describe('façade delegates to the existing emission primitives (call-site literals preserved)', () => {
284
+ it('openOrEditCard / mayDrain wrap drainActivitySummary at the call sites (not CALLED inside the façade)', () => {
285
+ // The façade does NOT IMPORT or CALL drainActivitySummary — the delegate
286
+ // thunk at the call site does, so the existing wiring oracle still sees it.
287
+ // (A prose mention in a doc-comment is fine.)
288
+ expect(facadeSrc).not.toMatch(/import .*drainActivitySummary/)
289
+ expect(facadeCode).not.toMatch(/drainActivitySummary\(/)
290
+ // The drain call literal is preserved inside an openOrEditCard delegate
291
+ // thunk: `openOrEditCard('<producer>', () => { turn.activityInFlight =
292
+ // drainActivitySummary(...) })`.
293
+ expect(gatewaySrc).toMatch(
294
+ /openOrEditCard\('[a-z]+', \(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary/,
295
+ )
296
+ })
297
+
298
+ it('finalizeCard wraps clearActivitySummary at the call sites (not CALLED inside the façade)', () => {
299
+ expect(facadeSrc).not.toMatch(/import .*clearActivitySummary/)
300
+ expect(facadeCode).not.toMatch(/clearActivitySummary\(/)
301
+ expect(gatewaySrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
302
+ })
303
+
304
+ it('claimOrDowngradePing — the call-site disabled thunk STILL contains a literal decideOverPing( (disabled-path proof)', () => {
305
+ // PR-4c: the façade's ENABLED branch now imports + calls `decideOverPing`
306
+ // (asserted in the dedicated describe below). But the DISABLED branch keeps
307
+ // its OWN literal `decideOverPing(` call inside the call-site thunk, VERBATIM
308
+ // from PR-4b-base — so the disabled path is provably byte-identical (never
309
+ // depends on the façade for the decision).
310
+ const pingIdx = gatewaySrc.indexOf('claimOrDowngradePing(')
311
+ expect(pingIdx).toBeGreaterThan(-1)
312
+ const window = gatewaySrc.slice(pingIdx, pingIdx + 1600)
313
+ expect(window).toMatch(/decideOverPing\(/)
314
+ })
315
+ })
316
+
317
+ describe('the 7 drain sites route through the façade with producers preserved verbatim', () => {
318
+ it('the narrative SHOW site routes via openOrEditCard("narrative") + the producer-"narrative" drain', () => {
319
+ const body = fnSrc('showNarrativeStep')
320
+ expect(body).toMatch(/openOrEditCard\('narrative'/)
321
+ // The drain literal (producer arg verbatim) is preserved in the delegate.
322
+ expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
323
+ // The single-flight read is routed through the pure mayDrain.
324
+ expect(body).toMatch(/ea\.mayDrain\(turn\)/)
325
+ })
326
+
327
+ it('both liveness sites in feedHeartbeatTick route via openOrEditCard("liveness") + producer-"liveness" drains', () => {
328
+ const body = fnSrc('feedHeartbeatTick')
329
+ const opens = [...body.matchAll(/openOrEditCard\('liveness'/g)]
330
+ expect(opens).toHaveLength(2)
331
+ const drains = [...body.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g)]
332
+ expect(drains).toHaveLength(2)
333
+ // The literal the feed-heartbeat oracle greps must still be present.
334
+ expect(body).toMatch(/turn\.activityInFlight = drainActivitySummary/)
335
+ })
336
+
337
+ it('the tool_label site routes via openOrEditCard("tool") + the producer-"tool" drain', () => {
338
+ expect(gatewaySrc).toMatch(/openOrEditCard\('tool',\s*\(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary\(turn,\s*'tool'\)/)
339
+ })
340
+
341
+ it('both foreground sub-agent drains route via openOrEditCard("tool") with producer made EXPLICIT', () => {
342
+ // Previously these called drainActivitySummary(turn) with the implicit
343
+ // 'tool' default; PR-4a makes 'tool' explicit at both, per the plan.
344
+ const opens = [...gatewaySrc.matchAll(/ea\.openOrEditCard\('tool', \(\) => \{\s*\n\s*turn\.activityInFlight = drainActivitySummary\(turn, 'tool'\)/g)]
345
+ // tool_label site (1) + the two sub-agent drains (2) = 3 'tool' openers.
346
+ expect(opens.length).toBeGreaterThanOrEqual(3)
347
+ // No foreground drain still uses the bare implicit-producer form.
348
+ expect(gatewaySrc).not.toMatch(/turn\.activityInFlight = drainActivitySummary\(turn\)\n/)
349
+ })
350
+
351
+ it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
352
+ const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
353
+ // narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness (Fix 2) = 7.
354
+ expect(mayDrainGuards).toHaveLength(7)
355
+ })
356
+ })
357
+
358
+ describe('the 2 lever-2 finalize blocks route through the façade', () => {
359
+ it('executeReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
360
+ const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
361
+ const body = after.split('async function executeStreamReply(')[0] ?? after
362
+ expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*finalizeTurn\.finalAnswerEverDelivered = true/)
363
+ expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(finalizeTurn\)/)
364
+ })
365
+
366
+ it('executeStreamReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
367
+ const after = gatewaySrc.split('async function executeStreamReply(')[1] ?? ''
368
+ const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
369
+ expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*turn\.finalAnswerEverDelivered = true/)
370
+ expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(turn\)/)
371
+ })
372
+ })
373
+
374
+ describe('per-turn construction — one façade per turn, explicit chat/thread key (PR-4e seam)', () => {
375
+ it('the turn ctor constructs a fresh EmissionAuthority with the explicit statusKey', () => {
376
+ // Per-turn: born in the CurrentTurn object literal so it is discarded with
377
+ // the turn and never persists across turns.
378
+ expect(gatewaySrc).toMatch(/emissionAuthority: new EmissionAuthority\(\s*\n\s*statusKey\(/)
379
+ })
380
+
381
+ it('the façade constructor takes the chat/thread key EXPLICITLY (the PR-4e map seam)', () => {
382
+ expect(facadeSrc).toMatch(/constructor\(private readonly chatKey: string\)/)
383
+ })
384
+
385
+ it('the accessor lazily backfills a per-turn façade keyed on the turn\'s statusKey', () => {
386
+ const body = fnSrc('emissionAuthorityFor')
387
+ expect(body).toMatch(/new EmissionAuthority\(\s*\n\s*statusKey\(turn\.sessionChatId, turn\.sessionThreadId\)/)
388
+ })
389
+ })
390
+
391
+ // ─── PR-4d block — APPENDED. The card-drain path now unifies the single-flight
392
+ // read with the #2137 deliver-before-drain gate, behind the kill-switch. The
393
+ // new façade method is a PURE read; the GATEWAY acquires chatLock around it.
394
+ // ────────────────────────────────────────────────────────────────────────────
395
+ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds the lock)', () => {
396
+ /** Body of `mayDrainCardNow` up to the next method declaration / class close. */
397
+ function cardNowBody(): string {
398
+ const after = facadeSrc.split('mayDrainCardNow(')[1] ?? ''
399
+ return after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
400
+ }
401
+
402
+ it('the new card-drain gate method EXISTS on the façade', () => {
403
+ expect(facadeSrc).toMatch(/mayDrainCardNow\(turn: EmissionTurnView, ctx: CardDrainGateCtx\): boolean/)
404
+ })
405
+
406
+ it('routes the single-flight via the PURE mayDrain (this.mayDrain(turn)) in BOTH branches', () => {
407
+ const body = cardNowBody()
408
+ // OFF (default) → exactly this.mayDrain(turn). ON → this.mayDrain(turn) && …
409
+ const mayDrainCalls = [...body.matchAll(/this\.mayDrain\(turn\)/g)]
410
+ expect(mayDrainCalls.length).toBeGreaterThanOrEqual(2)
411
+ // Enabled branch combines the single-flight read with the deliver-before-
412
+ // drain predicate.
413
+ expect(body).toMatch(/this\.mayDrain\(turn\)\s*&&\s*\n?\s*mayDrainBufferedInbound\(/)
414
+ })
415
+
416
+ it('imports the PURE mayDrainBufferedInbound from serialize-drain-gate.js (façade imports the helper, never the gateway)', () => {
417
+ expect(facadeSrc).toMatch(
418
+ /import\s*\{\s*mayDrainBufferedInbound\s*\}\s*from\s*'\.\/serialize-drain-gate\.js'/,
419
+ )
420
+ expect(facadeCode).toMatch(/mayDrainBufferedInbound\(/)
421
+ })
422
+
423
+ it('the deliver-before-drain term is consulted ONLY inside the EMISSION_AUTHORITY_ENABLED branch; OFF is byte-equivalent to mayDrain', () => {
424
+ const body = cardNowBody()
425
+ const flagIdx = body.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
426
+ const predIdx = body.indexOf('mayDrainBufferedInbound(')
427
+ expect(flagIdx).toBeGreaterThan(-1)
428
+ expect(predIdx).toBeGreaterThan(flagIdx)
429
+ // Exactly one mayDrainBufferedInbound call (the enabled branch).
430
+ expect([...body.matchAll(/mayDrainBufferedInbound\(/g)]).toHaveLength(1)
431
+ // The disabled fall-through is a bare `return this.mayDrain(turn)`.
432
+ expect(body).toMatch(/return this\.mayDrain\(turn\)\s*\n\s*\}/)
433
+ })
434
+
435
+ it('the card-gate method itself acquires NO lock — no chatLock / lock token in the body', () => {
436
+ const body = cardNowBody()
437
+ expect(body).not.toMatch(/chatLock|\.run\(|acquire|lock\(/i)
438
+ })
439
+
440
+ it('the gateway acquires the card-path chatLock — a SINGLE chatLock.run inside the EMISSION_AUTHORITY_ENABLED branch, NOT wrapping an await on drainActivitySummary/sendMessage', () => {
441
+ // The card-drain gate helper is the ONLY place the card path touches
442
+ // chatLock. Locate the helper and assert its shape.
443
+ const helperIdx = gatewaySrc.indexOf('function cardDrainGate(')
444
+ expect(helperIdx).toBeGreaterThan(-1)
445
+ const helper = gatewaySrc.slice(helperIdx, helperIdx + 700)
446
+ const helperCode = helper
447
+ .split('\n')
448
+ .filter((l) => {
449
+ const t = l.trim()
450
+ return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
451
+ })
452
+ .join('\n')
453
+ // The lock acquisition sits INSIDE the EMISSION_AUTHORITY_ENABLED branch.
454
+ const flagIdx = helperCode.indexOf('if (EMISSION_AUTHORITY_ENABLED)')
455
+ const lockIdx = helperCode.indexOf('chatLock.run(')
456
+ expect(flagIdx).toBeGreaterThan(-1)
457
+ expect(lockIdx).toBeGreaterThan(flagIdx)
458
+ // It consults the pure mayDrainCardNow inside the lock.
459
+ expect(helperCode).toMatch(/ea\.mayDrainCardNow\(turn, cardDrainGateCtx\(\)\)/)
460
+ // The lock body does NOT await drainActivitySummary / sendMessage — the
461
+ // drain assignment runs synchronously inside `run()`; the async send is NOT
462
+ // awaited inside the lock (so a card OPEN never holds chatLock across the
463
+ // gate's release).
464
+ expect(helperCode).not.toMatch(/await\s+drainActivitySummary/)
465
+ expect(helperCode).not.toMatch(/await\s+\w*[sS]endMessage/)
466
+ // Exactly ONE chatLock.run in the card-drain helper.
467
+ expect([...helperCode.matchAll(/chatLock\.run\(/g)]).toHaveLength(1)
468
+ })
469
+
470
+ it('the card-path gate threads endingTurnFinalAnswerDelivered: null (the §5 modeling decision)', () => {
471
+ // The gateway ctx builder fixes the card path to null so the deliver-before-
472
+ // drain predicate degenerates to !turnInFlight — the card single-flight is
473
+ // governed by activityInFlight, not an ending turn's delivery state.
474
+ const ctxIdx = gatewaySrc.indexOf('function cardDrainGateCtx(')
475
+ expect(ctxIdx).toBeGreaterThan(-1)
476
+ const ctxFn = gatewaySrc.slice(ctxIdx, ctxIdx + 400)
477
+ expect(ctxFn).toMatch(/endingTurnFinalAnswerDelivered:\s*null/)
478
+ expect(ctxFn).toMatch(/turnInFlight:\s*turnInFlightForGate\(\)/)
479
+ })
480
+
481
+ it('the 7 card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
482
+ // Option A: the 7 `if (ea.mayDrain(turn))` guards + drainActivitySummary
483
+ // thunks stay byte-identical, wrapped by the centralized helper.
484
+ // 6 original + 1 new post-answer background-agent liveness drain (Fix 2).
485
+ const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
486
+ expect(wraps).toHaveLength(7)
487
+ })
488
+ })
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import {
6
+ initHistory,
7
+ recordOutbound,
8
+ hasOutboundDeliveredSince,
9
+ _resetForTests,
10
+ } from '../history.js'
11
+ import {
12
+ mayOpenActivityCard,
13
+ computeCrossTurnAnswerDelivered,
14
+ type FeedOpenGateView,
15
+ type FeedOpenGateDeps,
16
+ type FeedOpenProducer,
17
+ } from '../gateway/feed-open-gate.js'
18
+ import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
19
+
20
+ /**
21
+ * PR-4b flag-parity proof — the HEART of the PR, at the façade layer.
22
+ *
23
+ * The emission-authority façade's `openOrEditCard` now RELOCATES main's drain
24
+ * OPEN-gate decision behind the `SWITCHROOM_EMISSION_AUTHORITY` kill-switch. The
25
+ * defining correctness property: flag-OFF ≡ flag-ON ≡ main — not one emitted
26
+ * card differs. This drives `openOrEditCard(producer, applySpy)` across the full
27
+ * input cross-product, for BOTH flag states, and asserts:
28
+ *
29
+ * - flag OFF → `applySpy` is ALWAYS called (4a behaviour — the drain's own
30
+ * gate is what refuses an OPEN; the façade is a pass-through).
31
+ * - flag ON → `applySpy` is called IFF `mayOpen` OR the card is already
32
+ * open (an EDIT is never gated); the SKIP set is exactly the cases where
33
+ * main would `break` (refuse an OPEN): `activityMessageId == null && !mayOpen`.
34
+ *
35
+ * The flag is read ONCE at module top (the asserted read-once invariant — we do
36
+ * NOT change that). We flip it per flag state by dynamically re-importing the
37
+ * façade module under a unique query string (bun re-evaluates the module, so the
38
+ * read-once const is recomputed against the chosen env). This is a TEST-ONLY
39
+ * seam — it does not touch the production read-once path. The history harness is
40
+ * real (a substantive row ⇒ a true cross-turn flag), so this also proves the
41
+ * wired predicate end-to-end.
42
+ */
43
+
44
+ let stateDir: string
45
+
46
+ const SUBSTANTIVE = 'A'.repeat(FINAL_ANSWER_MIN_CHARS)
47
+ const CHAT = '-100888'
48
+ const OPENED_AT_MS = 1_000_000 * 1000
49
+
50
+ const deps: FeedOpenGateDeps = {
51
+ hasOutboundDeliveredSince,
52
+ historyEnabled: true,
53
+ finalAnswerMinChars: FINAL_ANSWER_MIN_CHARS,
54
+ }
55
+
56
+ beforeEach(() => {
57
+ stateDir = mkdtempSync(join(tmpdir(), 'emission-open-gate-'))
58
+ initHistory(stateDir, 30)
59
+ })
60
+
61
+ afterEach(() => {
62
+ _resetForTests()
63
+ delete process.env.SWITCHROOM_EMISSION_AUTHORITY
64
+ if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
65
+ })
66
+
67
+ let reimportSeq = 0
68
+ /**
69
+ * Re-import the façade module with the flag set as requested. Bun re-evaluates a
70
+ * module imported under a fresh query string, so the read-once
71
+ * `EMISSION_AUTHORITY_ENABLED` const is recomputed against the env we set here —
72
+ * a test-only seam that leaves the production read-once path untouched.
73
+ */
74
+ async function loadFacade(enabled: boolean): Promise<typeof import('../gateway/emission-authority.js')> {
75
+ if (enabled) process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
76
+ else delete process.env.SWITCHROOM_EMISSION_AUTHORITY
77
+ return import(`../gateway/emission-authority.js?flagcase=${reimportSeq++}`)
78
+ }
79
+
80
+ // The full cross-product of OPEN-decision inputs (mirrors the verdict test).
81
+ type Case = {
82
+ crossTurnTrue: boolean
83
+ finalAnswerEverDelivered: boolean
84
+ producer: FeedOpenProducer
85
+ labeledToolCount: number
86
+ activityMessageId: number | null
87
+ }
88
+
89
+ function* cases(): Generator<Case> {
90
+ for (const crossTurnTrue of [false, true]) {
91
+ for (const finalAnswerEverDelivered of [false, true]) {
92
+ for (const producer of ['narrative', 'tool', 'liveness'] as FeedOpenProducer[]) {
93
+ for (const labeledToolCount of [0, 1]) {
94
+ for (const activityMessageId of [null, 123] as (number | null)[]) {
95
+ yield { crossTurnTrue, finalAnswerEverDelivered, producer, labeledToolCount, activityMessageId }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ function viewFor(c: Case): FeedOpenGateView {
104
+ return {
105
+ activityMessageId: c.activityMessageId,
106
+ finalAnswerEverDelivered: c.finalAnswerEverDelivered,
107
+ labeledToolCount: c.labeledToolCount,
108
+ crossTurnGate: { sinceMs: OPENED_AT_MS },
109
+ sessionChatId: CHAT,
110
+ sessionThreadId: null,
111
+ }
112
+ }
113
+
114
+ /** The exact `mayOpen` oracle (direct mayOpenActivityCard, real history flag). */
115
+ function oracleMayOpen(c: Case, view: FeedOpenGateView): boolean {
116
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered(view, deps)
117
+ return mayOpenActivityCard({
118
+ producer: c.producer,
119
+ finalAnswerEverDelivered: c.finalAnswerEverDelivered,
120
+ labeledToolCount: c.labeledToolCount,
121
+ crossTurnAnswerDelivered,
122
+ })
123
+ }
124
+
125
+ describe('openOrEditCard flag-parity — flag OFF always applies, flag ON applies iff main would not break', () => {
126
+ it('flag OFF: applySpy is ALWAYS called (4a pass-through, every input)', async () => {
127
+ const { EmissionAuthority } = await loadFacade(false)
128
+ for (const c of cases()) {
129
+ if (c.crossTurnTrue) {
130
+ recordOutbound({ chat_id: CHAT, thread_id: null, message_ids: [42], texts: [SUBSTANTIVE], ts: 1_000_001 })
131
+ }
132
+ const view = viewFor(c)
133
+ const ea = new EmissionAuthority('k')
134
+ ea.wireFeedOpenGate(() => view, deps)
135
+ const applySpy = vi.fn()
136
+ ea.openOrEditCard(c.producer, applySpy)
137
+ expect(applySpy).toHaveBeenCalledTimes(1)
138
+ _resetForTests()
139
+ initHistory(stateDir, 30)
140
+ }
141
+ })
142
+
143
+ it('flag ON: applySpy called IFF (isOpen || mayOpen); SKIP set == cases main would break', async () => {
144
+ const { EmissionAuthority } = await loadFacade(true)
145
+ const skippedKeys: string[] = []
146
+ const expectedBreakKeys: string[] = []
147
+ for (const c of cases()) {
148
+ if (c.crossTurnTrue) {
149
+ recordOutbound({ chat_id: CHAT, thread_id: null, message_ids: [42], texts: [SUBSTANTIVE], ts: 1_000_001 })
150
+ }
151
+ const view = viewFor(c)
152
+ const isOpen = c.activityMessageId != null
153
+ const mayOpen = oracleMayOpen(c, view)
154
+ // Main's drain refuses (break) iff about to OPEN AND !mayOpen.
155
+ const mainWouldBreak = !isOpen && !mayOpen
156
+
157
+ const ea = new EmissionAuthority('k')
158
+ ea.wireFeedOpenGate(() => view, deps)
159
+ const applySpy = vi.fn()
160
+ ea.openOrEditCard(c.producer, applySpy)
161
+
162
+ const key = `crossTurn=${c.crossTurnTrue} final=${c.finalAnswerEverDelivered} producer=${c.producer} tools=${c.labeledToolCount} msgId=${c.activityMessageId ?? 'null'}`
163
+ if (applySpy.mock.calls.length === 0) skippedKeys.push(key)
164
+ if (mainWouldBreak) expectedBreakKeys.push(key)
165
+
166
+ // Per-case parity: applied iff NOT(main would break).
167
+ expect(applySpy).toHaveBeenCalledTimes(mainWouldBreak ? 0 : 1)
168
+ // An already-open card (EDIT) is never gated under the flag.
169
+ if (isOpen) expect(applySpy).toHaveBeenCalledTimes(1)
170
+
171
+ _resetForTests()
172
+ initHistory(stateDir, 30)
173
+ }
174
+ // Set equality: the flag-ON skip set is EXACTLY main's break set.
175
+ expect(skippedKeys.sort()).toEqual(expectedBreakKeys.sort())
176
+ // Sanity: the skip set is non-empty (the gate actually fires for some input).
177
+ expect(skippedKeys.length).toBeGreaterThan(0)
178
+ })
179
+ })