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,395 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import {
5
+ decideOverPing,
6
+ type OverPingDecision,
7
+ } from '../over-ping-safety-net.js'
8
+ import type {
9
+ PingClaimInput,
10
+ PingClaimCtx,
11
+ } from '../gateway/emission-authority.js'
12
+
13
+ /**
14
+ * PR-4c flag-parity proof — the HEART of the PR, at the façade layer.
15
+ *
16
+ * The emission-authority façade's `claimOrDowngradePing` now RELOCATES the
17
+ * over-ping ownership decision behind the `SWITCHROOM_EMISSION_AUTHORITY`
18
+ * kill-switch. The defining correctness property: flag-OFF ≡ flag-ON ≡ the
19
+ * direct `decideOverPing` oracle — not one device ping differs in either flag
20
+ * state. This drives `claimOrDowngradePing(input, ctx, applyDecision, disabled)`
21
+ * across the full cross-product:
22
+ *
23
+ * incoming {ack, substantive, silent} × slot-held-by {none, ack-held,
24
+ * substantive-held}
25
+ *
26
+ * for BOTH flag states, against a fake turn + a stub `applyDecision` that
27
+ * records `(suppress, claimSlot, upgrade)` and APPLIES the mutations (the atomic
28
+ * `firstPingAt`/`firstPingWasSubstantive` pair-set), exactly like the gateway
29
+ * call site. It asserts:
30
+ *
31
+ * - flag-ON outcome set == flag-OFF outcome set == direct `decideOverPing`
32
+ * oracle set (the parity proof).
33
+ * - after a claim/upgrade the `firstPingAt`/`firstPingWasSubstantive` pair is
34
+ * consistently set (the #2562 atomicity invariant).
35
+ * - the at-most-once UPGRADE via the ack→upgrade→trailing-ack sequence.
36
+ *
37
+ * The flag is read ONCE at module top (the asserted read-once invariant — we do
38
+ * NOT change that). We flip it per flag state by dynamically re-importing the
39
+ * façade module under a unique query string (bun/vite re-evaluates the module,
40
+ * so the read-once const is recomputed against the chosen env). This is a
41
+ * TEST-ONLY seam — it leaves the production read-once path untouched. Mirrors
42
+ * the `loadFacade` seam in emission-authority-open-gate.test.ts. NO sqlite
43
+ * import, so this runs cleanly under both vitest and bun.
44
+ */
45
+
46
+ const NOW = 1_700_000_000_000
47
+ const PRIOR_PING_AT = NOW - 5_000
48
+
49
+ afterEach(() => {
50
+ delete process.env.SWITCHROOM_EMISSION_AUTHORITY
51
+ })
52
+
53
+ let reimportSeq = 0
54
+ async function loadFacade(
55
+ enabled: boolean,
56
+ ): Promise<typeof import('../gateway/emission-authority.js')> {
57
+ if (enabled) process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
58
+ else delete process.env.SWITCHROOM_EMISSION_AUTHORITY
59
+ return import(`../gateway/emission-authority.js?pingcase=${reimportSeq++}`)
60
+ }
61
+
62
+ /** A minimal stand-in for the turn's ping-slot state. */
63
+ interface FakeTurn {
64
+ firstPingAt: number | null
65
+ firstPingWasSubstantive: boolean
66
+ }
67
+
68
+ type SlotHeld = 'none' | 'ack' | 'substantive'
69
+ type Incoming = 'ack' | 'substantive' | 'silent'
70
+
71
+ function turnFor(slot: SlotHeld): FakeTurn {
72
+ switch (slot) {
73
+ case 'none':
74
+ return { firstPingAt: null, firstPingWasSubstantive: false }
75
+ case 'ack':
76
+ return { firstPingAt: PRIOR_PING_AT, firstPingWasSubstantive: false }
77
+ case 'substantive':
78
+ return { firstPingAt: PRIOR_PING_AT, firstPingWasSubstantive: true }
79
+ }
80
+ }
81
+
82
+ function inputFor(incoming: Incoming): PingClaimInput {
83
+ switch (incoming) {
84
+ case 'silent':
85
+ return { modelRequestedPing: false, substantive: false }
86
+ case 'ack':
87
+ return { modelRequestedPing: true, substantive: false }
88
+ case 'substantive':
89
+ return { modelRequestedPing: true, substantive: true }
90
+ }
91
+ }
92
+
93
+ /** The recorded outcome of one drive — what the call site would observe. */
94
+ interface Outcome {
95
+ suppress: boolean
96
+ claimSlot: boolean
97
+ upgrade: boolean
98
+ /** Post-state of the (atomic) pair after applyDecision ran. */
99
+ firstPingAt: number | null
100
+ firstPingWasSubstantive: boolean
101
+ /** Did the call-site closure write disableNotification=true? */
102
+ disableNotification: boolean
103
+ }
104
+
105
+ /**
106
+ * Drive `claimOrDowngradePing` once for the given flag state, mirroring the
107
+ * gateway call site: the `applyDecision` thunk records the decision AND applies
108
+ * the atomic pair-set + the disableNotification closure write; the `disabled`
109
+ * thunk computes its own decision via the literal `decideOverPing` and routes it
110
+ * through the SAME `applyDecision`.
111
+ */
112
+ async function drive(
113
+ enabled: boolean,
114
+ incoming: Incoming,
115
+ slot: SlotHeld,
116
+ ): Promise<Outcome> {
117
+ const { EmissionAuthority } = await loadFacade(enabled)
118
+ const turn = turnFor(slot)
119
+ const input = inputFor(incoming)
120
+ const ctx: PingClaimCtx = {
121
+ firstPingAt: turn.firstPingAt,
122
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
123
+ nowMs: NOW,
124
+ }
125
+ const replySubstantive = input.substantive
126
+ let disableNotification = false
127
+ let recorded: OverPingDecision | null = null
128
+
129
+ const applyDecision = (decision: OverPingDecision): void => {
130
+ recorded = decision
131
+ if (decision.suppress) {
132
+ disableNotification = true
133
+ } else if (decision.claimSlot) {
134
+ // The atomic two-adjacent-line pair-set (no await between).
135
+ turn.firstPingAt = NOW
136
+ turn.firstPingWasSubstantive = replySubstantive
137
+ }
138
+ }
139
+
140
+ new EmissionAuthority('k').claimOrDowngradePing(
141
+ input,
142
+ ctx,
143
+ applyDecision,
144
+ () => {
145
+ const decision = decideOverPing({
146
+ modelRequestedPing: input.modelRequestedPing,
147
+ firstPingAt: turn.firstPingAt,
148
+ substantive: replySubstantive,
149
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
150
+ nowMs: NOW,
151
+ })
152
+ applyDecision(decision)
153
+ },
154
+ )
155
+
156
+ const d = recorded as OverPingDecision | null
157
+ if (d == null) throw new Error('applyDecision was never called')
158
+ return {
159
+ suppress: d.suppress,
160
+ claimSlot: d.claimSlot,
161
+ upgrade: d.upgrade,
162
+ firstPingAt: turn.firstPingAt,
163
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
164
+ disableNotification,
165
+ }
166
+ }
167
+
168
+ /** The direct oracle: `decideOverPing` over the same inputs, then the effects. */
169
+ function oracle(incoming: Incoming, slot: SlotHeld): Outcome {
170
+ const turn = turnFor(slot)
171
+ const input = inputFor(incoming)
172
+ const decision = decideOverPing({
173
+ modelRequestedPing: input.modelRequestedPing,
174
+ firstPingAt: turn.firstPingAt,
175
+ substantive: input.substantive,
176
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
177
+ nowMs: NOW,
178
+ })
179
+ let disableNotification = false
180
+ if (decision.suppress) {
181
+ disableNotification = true
182
+ } else if (decision.claimSlot) {
183
+ turn.firstPingAt = NOW
184
+ turn.firstPingWasSubstantive = input.substantive
185
+ }
186
+ return {
187
+ suppress: decision.suppress,
188
+ claimSlot: decision.claimSlot,
189
+ upgrade: decision.upgrade,
190
+ firstPingAt: turn.firstPingAt,
191
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
192
+ disableNotification,
193
+ }
194
+ }
195
+
196
+ const INCOMING: Incoming[] = ['ack', 'substantive', 'silent']
197
+ const SLOTS: SlotHeld[] = ['none', 'ack', 'substantive']
198
+
199
+ describe('claimOrDowngradePing flag-parity — flag-ON ≡ flag-OFF ≡ decideOverPing oracle', () => {
200
+ for (const incoming of INCOMING) {
201
+ for (const slot of SLOTS) {
202
+ it(`incoming=${incoming} slot-held-by=${slot}: ON == OFF == oracle`, async () => {
203
+ const off = await drive(false, incoming, slot)
204
+ const on = await drive(true, incoming, slot)
205
+ const want = oracle(incoming, slot)
206
+ expect(off).toEqual(want)
207
+ expect(on).toEqual(want)
208
+ // Atomicity: after a claim/upgrade the pair is consistently set.
209
+ if (on.claimSlot) {
210
+ expect(on.firstPingAt).toBe(NOW)
211
+ expect(on.firstPingWasSubstantive).toBe(incoming === 'substantive')
212
+ }
213
+ })
214
+ }
215
+ }
216
+
217
+ it('full set equality — the ON outcome set equals the OFF set equals the oracle set', async () => {
218
+ const onSet: Outcome[] = []
219
+ const offSet: Outcome[] = []
220
+ const oracleSet: Outcome[] = []
221
+ for (const incoming of INCOMING) {
222
+ for (const slot of SLOTS) {
223
+ offSet.push(await drive(false, incoming, slot))
224
+ onSet.push(await drive(true, incoming, slot))
225
+ oracleSet.push(oracle(incoming, slot))
226
+ }
227
+ }
228
+ expect(onSet).toEqual(oracleSet)
229
+ expect(offSet).toEqual(oracleSet)
230
+ })
231
+ })
232
+
233
+ describe('claimOrDowngradePing — the documented R8 ownership outcomes', () => {
234
+ it('substantive over an ack-held slot UPGRADES (pings + claims, no suppress)', async () => {
235
+ for (const enabled of [false, true]) {
236
+ const o = await drive(enabled, 'substantive', 'ack')
237
+ expect(o.suppress).toBe(false)
238
+ expect(o.claimSlot).toBe(true)
239
+ expect(o.upgrade).toBe(true)
240
+ expect(o.disableNotification).toBe(false)
241
+ // The slot is upgraded to substantive.
242
+ expect(o.firstPingAt).toBe(NOW)
243
+ expect(o.firstPingWasSubstantive).toBe(true)
244
+ }
245
+ })
246
+
247
+ it('ack over a substantive-held slot SUPPRESSES (no spurious double-ping)', async () => {
248
+ for (const enabled of [false, true]) {
249
+ const o = await drive(enabled, 'ack', 'substantive')
250
+ expect(o.suppress).toBe(true)
251
+ expect(o.claimSlot).toBe(false)
252
+ expect(o.disableNotification).toBe(true)
253
+ }
254
+ })
255
+
256
+ it('substantive over a substantive-held slot SUPPRESSES (#1674 answer+wrap-up guard)', async () => {
257
+ for (const enabled of [false, true]) {
258
+ const o = await drive(enabled, 'substantive', 'substantive')
259
+ expect(o.suppress).toBe(true)
260
+ expect(o.claimSlot).toBe(false)
261
+ }
262
+ })
263
+
264
+ it('first ping (no slot held) CLAIMS without upgrade', async () => {
265
+ for (const enabled of [false, true]) {
266
+ const o = await drive(enabled, 'ack', 'none')
267
+ expect(o.suppress).toBe(false)
268
+ expect(o.claimSlot).toBe(true)
269
+ expect(o.upgrade).toBe(false)
270
+ expect(o.firstPingAt).toBe(NOW)
271
+ }
272
+ })
273
+
274
+ it('silent incoming is a no-op (the safety net never touches a model-silent reply)', async () => {
275
+ for (const enabled of [false, true]) {
276
+ for (const slot of SLOTS) {
277
+ const o = await drive(enabled, 'silent', slot)
278
+ expect(o.suppress).toBe(false)
279
+ expect(o.claimSlot).toBe(false)
280
+ expect(o.upgrade).toBe(false)
281
+ expect(o.disableNotification).toBe(false)
282
+ }
283
+ }
284
+ })
285
+ })
286
+
287
+ describe('claimOrDowngradePing — at-most-once upgrade (ack → upgrade → trailing-ack)', () => {
288
+ /**
289
+ * Drive a 3-reply turn against ONE persistent fake turn (the façade decides,
290
+ * the applyDecision thunk mutates the shared turn): an interim ACK claims the
291
+ * slot, the SUBSTANTIVE answer UPGRADES it (one bounded second ping), and a
292
+ * trailing ACK is then SUPPRESSED — at most ONE upgrade per turn.
293
+ */
294
+ async function driveSequence(
295
+ enabled: boolean,
296
+ ): Promise<{ upgrades: number; pings: number; suppresses: number }> {
297
+ const { EmissionAuthority } = await loadFacade(enabled)
298
+ const turn: FakeTurn = { firstPingAt: null, firstPingWasSubstantive: false }
299
+ const ea = new EmissionAuthority('k')
300
+ let upgrades = 0
301
+ let pings = 0
302
+ let suppresses = 0
303
+ let tick = 0
304
+
305
+ const step = (incoming: Incoming): void => {
306
+ const input = inputFor(incoming)
307
+ const replySubstantive = input.substantive
308
+ const now = NOW + tick++
309
+ const ctx: PingClaimCtx = {
310
+ firstPingAt: turn.firstPingAt,
311
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
312
+ nowMs: now,
313
+ }
314
+ const applyDecision = (decision: OverPingDecision): void => {
315
+ if (decision.upgrade) upgrades++
316
+ if (decision.suppress) suppresses++
317
+ if (decision.claimSlot) {
318
+ // A claim/upgrade lets the ping through.
319
+ pings++
320
+ turn.firstPingAt = now
321
+ turn.firstPingWasSubstantive = replySubstantive
322
+ }
323
+ }
324
+ ea.claimOrDowngradePing(input, ctx, applyDecision, () => {
325
+ const decision = decideOverPing({
326
+ modelRequestedPing: input.modelRequestedPing,
327
+ firstPingAt: turn.firstPingAt,
328
+ substantive: replySubstantive,
329
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
330
+ nowMs: now,
331
+ })
332
+ applyDecision(decision)
333
+ })
334
+ }
335
+
336
+ step('ack') // interim ack — claims the slot (first ping)
337
+ step('substantive') // answer — UPGRADES over the ack's slot (second ping)
338
+ step('ack') // trailing ack — suppressed
339
+ return { upgrades, pings, suppresses }
340
+ }
341
+
342
+ for (const enabled of [false, true]) {
343
+ it(`flag ${enabled ? 'ON' : 'OFF'}: exactly one upgrade, two pings (ack-claim + answer-upgrade), one suppress`, async () => {
344
+ const { upgrades, pings, suppresses } = await driveSequence(enabled)
345
+ expect(upgrades).toBe(1)
346
+ expect(pings).toBe(2)
347
+ expect(suppresses).toBe(1)
348
+ })
349
+ }
350
+ })
351
+
352
+ describe('claimOrDowngradePing — no await in the synchronous decide→apply→pair-set chain (source-read)', () => {
353
+ const facadeSrc = readFileSync(
354
+ resolve(__dirname, '..', 'gateway', 'emission-authority.ts'),
355
+ 'utf-8',
356
+ )
357
+ const gatewaySrc = readFileSync(
358
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
359
+ 'utf-8',
360
+ )
361
+
362
+ it('the façade method is synchronous and await-free', () => {
363
+ expect(facadeSrc).not.toMatch(/async\s+claimOrDowngradePing/)
364
+ const after = facadeSrc.split('claimOrDowngradePing(')[1] ?? ''
365
+ const body = after.split(/\n [A-Za-z]+[(<]/)[0] ?? after
366
+ expect(body).not.toMatch(/\bawait\b/)
367
+ })
368
+
369
+ it('the call-site over-ping block is await-free (atomic pair-set preserved)', () => {
370
+ // Bound the window to the over-ping block itself — from the
371
+ // `applyOverPingDecision` thunk to the end of the `claimOrDowngradePing`
372
+ // call, BEFORE the Telegraph block below (which legitimately awaits).
373
+ const blockStart = gatewaySrc.indexOf('const applyOverPingDecision')
374
+ const telegraphIdx = gatewaySrc.indexOf('// Telegraph publish (#579)', blockStart)
375
+ expect(blockStart).toBeGreaterThan(-1)
376
+ expect(telegraphIdx).toBeGreaterThan(blockStart)
377
+ const block = gatewaySrc.slice(blockStart, telegraphIdx)
378
+ // Strip comment lines so a prose "no await between" in a doc-comment does
379
+ // not trip the executable-await check.
380
+ const blockCode = block
381
+ .split('\n')
382
+ .filter((l) => {
383
+ const t = l.trim()
384
+ return !(t.startsWith('*') || t.startsWith('//') || t.startsWith('/*'))
385
+ })
386
+ .join('\n')
387
+ // Sanity: the single claimOrDowngradePing call is inside this window.
388
+ expect(blockCode).toMatch(/\.claimOrDowngradePing\(/)
389
+ expect(blockCode).not.toMatch(/\bawait\b/)
390
+ // The two-adjacent-line pair-set is intact.
391
+ expect(block).toMatch(
392
+ /turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
393
+ )
394
+ })
395
+ })
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Emission-determinism wiring — structural (source-read) assertions for the
3
+ * deterministic activity-card OPEN gating + reply-is-last ordering changes
4
+ * (design `docs/message-emission-determinism.md` §9 levers 1, 2, 5; #2556).
5
+ *
6
+ * The gateway IIFE can't be instantiated in-process, so these pin the
7
+ * load-bearing wiring by reading gateway.ts source — same pattern as
8
+ * activity-ever-opened-sticky.test.ts / feed-heartbeat-liveness-open.test.ts.
9
+ * The pure decision logic (mayOpenActivityCard) is exercised behaviourally in
10
+ * feed-open-gate.test.ts; this file guards that the gateway is wired to it.
11
+ */
12
+ import { describe, it, expect } from 'vitest'
13
+ import { readFileSync } from 'node:fs'
14
+ import { resolve } from 'node:path'
15
+
16
+ const gatewaySrc = readFileSync(
17
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
18
+ 'utf-8',
19
+ )
20
+
21
+ /** Source of `drainActivitySummary` up to the next top-level function. */
22
+ function drainSrc(): string {
23
+ const after = gatewaySrc.split('async function drainActivitySummary(')[1] ?? ''
24
+ return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
25
+ }
26
+
27
+ describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', () => {
28
+ it('is initialised false in the turn object literal (per-turn reset at turn start)', () => {
29
+ expect(gatewaySrc).toMatch(/finalAnswerEverDelivered:\s*false/)
30
+ })
31
+
32
+ it('is reset to false in exactly ONE place — the turn initialiser (never cleared by reopen)', () => {
33
+ // Mirrors activityEverOpened's sticky-true contract: the only `false` is the
34
+ // per-turn init. A standalone `= false` reassignment would let reopen clear
35
+ // the latch and reintroduce the reorder (the R0 correction).
36
+ const initFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered:\s*false/g)]
37
+ expect(initFalse).toHaveLength(1)
38
+ const resetFalse = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*false/g)]
39
+ expect(resetFalse).toHaveLength(0)
40
+ })
41
+
42
+ it('feed-reopen-gate.ts resets only the MUTABLE flag, never the sticky latch (#2141 preserved)', () => {
43
+ const reopenSrc = readFileSync(
44
+ resolve(__dirname, '..', 'gateway', 'feed-reopen-gate.ts'),
45
+ 'utf-8',
46
+ )
47
+ expect(reopenSrc).toMatch(/finalAnswerDelivered:\s*false/)
48
+ expect(reopenSrc).not.toMatch(/finalAnswerEverDelivered/)
49
+ })
50
+
51
+ it('every site that sets the sticky latch true gates on finalAnswerSubstantive', () => {
52
+ // The latch is set true only at the points that set finalAnswerDelivered=true,
53
+ // and only when the reply was substantive — so an ack never latches it.
54
+ const setTrue = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
55
+ // executeReply, executeStreamReply, silent-anchor merge, + the two lever-2
56
+ // finalize blocks (which are themselves substantive-gated).
57
+ expect(setTrue.length).toBeGreaterThanOrEqual(3)
58
+ // Each `finalAnswerEverDelivered = true` must sit in a substantive context:
59
+ // either guarded by `if (turn.finalAnswerSubstantive)` or inside an
60
+ // `isSubstantiveFinalReply(...)` branch. Assert the substantive-gating
61
+ // token co-occurs (no bare unconditional latch set).
62
+ const bareUnconditional = [
63
+ ...gatewaySrc.matchAll(/\n\s*(?:turn|finalizeTurn)\.finalAnswerEverDelivered\s*=\s*true/g),
64
+ ]
65
+ for (const m of bareUnconditional) {
66
+ const idx = m.index ?? 0
67
+ const window = gatewaySrc.slice(Math.max(0, idx - 600), idx)
68
+ expect(
69
+ /finalAnswerSubstantive/.test(window) || /isSubstantiveFinalReply/.test(window),
70
+ ).toBe(true)
71
+ }
72
+ })
73
+ })
74
+
75
+ describe('drainActivitySummary OPEN gate (levers 1 + 5, heartbeat-covered)', () => {
76
+ it('consults mayOpenActivityCard in the OPEN branch (activityMessageId == null)', () => {
77
+ const src = drainSrc()
78
+ expect(src).toMatch(/mayOpenActivityCard\(/)
79
+ // The gate is keyed on the sticky latch + labeledToolCount + producer.
80
+ expect(src).toMatch(/finalAnswerEverDelivered:\s*turn\.finalAnswerEverDelivered/)
81
+ expect(src).toMatch(/labeledToolCount:\s*turn\.labeledToolCount/)
82
+ expect(src).toMatch(/producer/)
83
+ })
84
+
85
+ it('refusing an OPEN does NOT advance activityLastSentRender (break, not mark-sent)', () => {
86
+ // The gate check must `break` out of the drain loop when an OPEN is refused,
87
+ // BEFORE the activityLastSentRender = target write — otherwise the
88
+ // accumulated render is marked sent and a later OPEN-eligible producer
89
+ // (a tool label) skips it via the `pending !== lastSent` guard.
90
+ const src = drainSrc()
91
+ const gateIdx = src.indexOf('mayOpenActivityCard(')
92
+ const lastSentIdx = src.indexOf('activityLastSentRender = target')
93
+ expect(gateIdx).toBeGreaterThan(-1)
94
+ expect(lastSentIdx).toBeGreaterThan(-1)
95
+ expect(gateIdx).toBeLessThan(lastSentIdx)
96
+ // A `break` follows the gate check.
97
+ const afterGate = src.slice(gateIdx, lastSentIdx)
98
+ expect(afterGate).toMatch(/break/)
99
+ })
100
+
101
+ it('the gate only applies to the OPEN branch — guarded on activityMessageId == null', () => {
102
+ const src = drainSrc()
103
+ const gateIdx = src.indexOf('mayOpenActivityCard(')
104
+ const window = src.slice(Math.max(0, gateIdx - 200), gateIdx)
105
+ expect(window).toMatch(/activityMessageId == null/)
106
+ })
107
+ })
108
+
109
+ describe('drain producers — narrative may not OPEN, liveness + tool may', () => {
110
+ it('showNarrativeStep drains with producer "narrative" (lever 5 base case)', () => {
111
+ const after = gatewaySrc.split('function showNarrativeStep(')[1] ?? ''
112
+ const body = after.split('\nfunction ')[0] ?? after
113
+ expect(body).toMatch(/drainActivitySummary\(turn,\s*'narrative'\)/)
114
+ })
115
+
116
+ it('the liveness-open path drains with producer "liveness" (producer C preserved)', () => {
117
+ const after = gatewaySrc.split('function feedHeartbeatTick(')[1] ?? ''
118
+ const body = after.split('\nfunction ')[0] ?? after
119
+ expect(body).toMatch(/drainActivitySummary\(turn,\s*'liveness'\)/)
120
+ })
121
+
122
+ it('the tool_label path drains with producer "tool" (always OPEN-eligible)', () => {
123
+ expect(gatewaySrc).toMatch(/drainActivitySummary\(turn,\s*'tool'\)/)
124
+ })
125
+ })
126
+
127
+ describe('lever 2 — finalize the card BEFORE a substantive reply send', () => {
128
+ /** executeReply body up to executeStreamReply. */
129
+ function executeReplySrc(): string {
130
+ const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
131
+ return after.split('async function executeStreamReply(')[0] ?? after
132
+ }
133
+ /** executeStreamReply body up to the next top-level function. */
134
+ function executeStreamReplySrc(): string {
135
+ const after = gatewaySrc.split('async function executeStreamReply(')[1] ?? ''
136
+ return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
137
+ }
138
+
139
+ it('executeReply finalizes (clearActivitySummary) before the chunk loop, gated on substantive', () => {
140
+ const src = executeReplySrc()
141
+ const clearIdx = src.indexOf('clearActivitySummary(')
142
+ const loopIdx = src.indexOf('for (let i = 0; i < chunks.length')
143
+ expect(clearIdx).toBeGreaterThan(-1)
144
+ expect(loopIdx).toBeGreaterThan(-1)
145
+ expect(clearIdx).toBeLessThan(loopIdx)
146
+ // The finalize is substantive-gated (acks do nothing — R3/#2141).
147
+ const window = src.slice(Math.max(0, clearIdx - 500), clearIdx)
148
+ expect(window).toMatch(/isSubstantiveFinalReply/)
149
+ })
150
+
151
+ it('executeStreamReply finalizes before handleStreamReply, gated on substantive', () => {
152
+ const src = executeStreamReplySrc()
153
+ const clearIdx = src.indexOf('clearActivitySummary(')
154
+ const sendIdx = src.indexOf('const result = await handleStreamReply(')
155
+ expect(clearIdx).toBeGreaterThan(-1)
156
+ expect(sendIdx).toBeGreaterThan(-1)
157
+ expect(clearIdx).toBeLessThan(sendIdx)
158
+ // Window extended to 600 chars to account for the finalAnswerDeliveredAt stamp
159
+ // added inside the markSubstantiveFinalDelivered callback (Fix 2 / #2587).
160
+ const window = src.slice(Math.max(0, clearIdx - 600), clearIdx)
161
+ expect(window).toMatch(/isSubstantiveFinalReply/)
162
+ })
163
+
164
+ it('acks do NOT finalize early — no unconditional clearActivitySummary before the reply send', () => {
165
+ // Both lever-2 finalize sites sit inside an isSubstantiveFinalReply guard.
166
+ // An ack (non-substantive) falls through and never finalizes early, so the
167
+ // reopen path keeps owning the card (the #2141 ack-then-work feed).
168
+ const replySrc = (() => {
169
+ const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
170
+ return after.split('async function executeStreamReply(')[0] ?? after
171
+ })()
172
+ // The pre-loop clearActivitySummary must be the substantive-gated one.
173
+ const preLoop = replySrc.split('for (let i = 0; i < chunks.length')[0] ?? ''
174
+ const clears = [...preLoop.matchAll(/clearActivitySummary\(/g)]
175
+ expect(clears).toHaveLength(1)
176
+ })
177
+ })