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,424 @@
1
+ import { describe, it, expect, beforeEach, afterEach } 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 as realComputeCrossTurnAnswerDelivered,
14
+ computeFeedOpenVerdict,
15
+ type FeedOpenGateView,
16
+ type FeedOpenGateDeps,
17
+ type FeedOpenProducer,
18
+ } from '../gateway/feed-open-gate.js'
19
+ import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
20
+
21
+ /**
22
+ * PR1 — cross-turn stale-card guard (design `docs/message-emission-determinism.md`
23
+ * §9 lever 4 / race C/D).
24
+ *
25
+ * Scenario this pins: a substantive answer is delivered in turn N (the original
26
+ * obligation turn). The obligation does NOT close (its reply routing didn't
27
+ * resolve back to the origin), so `obligationSweep` later RE-PRESENTS it as a
28
+ * synthetic owed-reply turn N+1. That synthetic turn (and the liveness/heartbeat
29
+ * timer firing on it) starts with a CLEARED per-turn `finalAnswerEverDelivered`
30
+ * latch, so lever 1 alone can't see the prior answer. Lever 4 closes the gap by
31
+ * composing, exactly as `drainActivitySummary` does, the
32
+ * `crossTurnAnswerDelivered` flag from the SAME `hasOutboundDeliveredSince`
33
+ * history predicate the represent guard uses — with the obligation's `openedAt`
34
+ * as the cutoff and the SUBSTANTIVE 200-char threshold — and passing it to
35
+ * `mayOpenActivityCard`.
36
+ *
37
+ * This exercises the wired composition (cutoff = obligation openedAt, threshold =
38
+ * FINAL_ANSWER_MIN_CHARS) against a real history DB, the part the pure
39
+ * feed-open-gate unit test cannot cover. The gateway computes the same expression
40
+ * inline in the OPEN branch of `drainActivitySummary`.
41
+ */
42
+
43
+ let stateDir: string
44
+
45
+ const SUBSTANTIVE = 'A'.repeat(FINAL_ANSWER_MIN_CHARS) // ≥ 200 chars → counts
46
+ const ACK = 'On it.' // < 200 chars → never counts (the #2141 carve-out)
47
+
48
+ const CHAT = '-100777'
49
+
50
+ beforeEach(() => {
51
+ stateDir = mkdtempSync(join(tmpdir(), 'cross-turn-card-gate-'))
52
+ initHistory(stateDir, 30)
53
+ })
54
+
55
+ afterEach(() => {
56
+ _resetForTests()
57
+ if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
58
+ })
59
+
60
+ /**
61
+ * PR-4b: drive the REAL extracted helper (`computeCrossTurnAnswerDelivered` in
62
+ * feed-open-gate.ts) — the same pure function the gateway drain AND the
63
+ * emission-authority façade now call — rather than a parallel local mirror.
64
+ * Maps this test's scenario shape onto the helper's `(view, deps)` signature:
65
+ * `aboutToOpen` ⇒ `activityMessageId == null`, `hasCrossTurnGate` ⇒ the
66
+ * `crossTurnGate` presence, `openedAt` ⇒ `crossTurnGate.sinceMs` (the
67
+ * obligation cutoff). Deps inject the SAME `hasOutboundDeliveredSince` predicate
68
+ * + the substantive 200-char floor the gateway centralizes.
69
+ */
70
+ function computeCrossTurnAnswerDelivered(opts: {
71
+ aboutToOpen: boolean
72
+ hasCrossTurnGate: boolean
73
+ openedAt: number
74
+ threadId?: number
75
+ }): boolean {
76
+ return realComputeCrossTurnAnswerDelivered(
77
+ {
78
+ activityMessageId: opts.aboutToOpen ? null : 1,
79
+ finalAnswerEverDelivered: false,
80
+ labeledToolCount: 0,
81
+ crossTurnGate: opts.hasCrossTurnGate ? { sinceMs: opts.openedAt } : undefined,
82
+ sessionChatId: CHAT,
83
+ sessionThreadId: opts.threadId,
84
+ },
85
+ {
86
+ hasOutboundDeliveredSince,
87
+ historyEnabled: true,
88
+ finalAnswerMinChars: FINAL_ANSWER_MIN_CHARS,
89
+ },
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Mirror the gateway's per-turn identity (`deriveTurnId`) and the
95
+ * `pendingCrossTurnGate` arm/consume keying. The gate is keyed on the
96
+ * obligation's `originTurnId` — which equals `deriveTurnId(chat, thread, msgId)`
97
+ * for the originating inbound, and which the represent inbound reconstructs
98
+ * (it reuses the original chat/thread/messageId). The consume side looks the
99
+ * gate up by the ENQUEUE-time turn id of whatever turn is starting, deleting
100
+ * on read. So a turn consumes (and thus carries) the gate iff its own
101
+ * derived turn id equals the armed obligation's `originTurnId` — which is true
102
+ * for the matching represent turn and false for any unrelated foreground turn.
103
+ */
104
+ function deriveTurnIdMirror(
105
+ chatId: string,
106
+ threadId: number | null | undefined,
107
+ messageId: string | number | null | undefined,
108
+ ): string | null {
109
+ if (messageId == null || messageId === '' || String(messageId) === '0') return null
110
+ return `${chatId}:${threadId ?? '_'}#${messageId}`
111
+ }
112
+
113
+ /** Arm side (`obligationSweep`): key by the obligation's originTurnId. */
114
+ function armGate(
115
+ gate: Map<string, { sinceMs: number }>,
116
+ originTurnId: string,
117
+ openedAt: number,
118
+ ): void {
119
+ gate.set(originTurnId, { sinceMs: openedAt })
120
+ }
121
+
122
+ /**
123
+ * Consume side (enqueue / turn ctor): look up + delete by the STARTING turn's
124
+ * own derived turn id. Returns whether this turn carries a cross-turn gate.
125
+ */
126
+ function consumeGate(
127
+ gate: Map<string, { sinceMs: number }>,
128
+ turnId: string,
129
+ ): { sinceMs: number } | undefined {
130
+ const hit = gate.get(turnId)
131
+ if (hit != null) gate.delete(turnId)
132
+ return hit
133
+ }
134
+
135
+ describe('cross-turn card gate — synthetic represent turn AFTER a substantive answer', () => {
136
+ it('does NOT open a card when a substantive answer was delivered since the obligation was raised', () => {
137
+ const openedAt = 1_000_000 * 1000 // ms — obligation raised
138
+ // Turn N delivered a substantive answer 1s after the obligation was raised.
139
+ recordOutbound({
140
+ chat_id: CHAT,
141
+ thread_id: null,
142
+ message_ids: [42],
143
+ texts: [SUBSTANTIVE],
144
+ ts: 1_000_001, // sec — after openedAt
145
+ })
146
+
147
+ // Turn N+1 is the synthetic represent turn: fresh latch, about to OPEN.
148
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
149
+ aboutToOpen: true,
150
+ hasCrossTurnGate: true,
151
+ openedAt,
152
+ })
153
+ expect(crossTurnAnswerDelivered).toBe(true)
154
+
155
+ // Any producer's OPEN is refused — no "thinking…" card beneath the answer.
156
+ for (const producer of ['tool', 'liveness', 'narrative'] as const) {
157
+ expect(
158
+ mayOpenActivityCard({
159
+ producer,
160
+ finalAnswerEverDelivered: false, // the synthetic turn's own latch is clear
161
+ labeledToolCount: producer === 'narrative' ? 0 : 1,
162
+ crossTurnAnswerDelivered,
163
+ }),
164
+ ).toBe(false)
165
+ }
166
+ })
167
+
168
+ it('DOES open a card when the obligation is genuinely unanswered (no reply since it was raised) — represent surface preserved', () => {
169
+ const openedAt = 1_000_000 * 1000
170
+ // No outbound recorded at all → genuinely unanswered.
171
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
172
+ aboutToOpen: true,
173
+ hasCrossTurnGate: true,
174
+ openedAt,
175
+ })
176
+ expect(crossTurnAnswerDelivered).toBe(false)
177
+
178
+ // The represent turn's card opens normally (a tool label / liveness OPEN).
179
+ expect(
180
+ mayOpenActivityCard({
181
+ producer: 'tool',
182
+ finalAnswerEverDelivered: false,
183
+ labeledToolCount: 1,
184
+ crossTurnAnswerDelivered,
185
+ }),
186
+ ).toBe(true)
187
+ })
188
+
189
+ it('does NOT regress #2141: only an ACK was delivered since the obligation was raised → card still opens', () => {
190
+ const openedAt = 1_000_000 * 1000
191
+ // Turn N sent only a short ack ("On it.") — NOT substantive.
192
+ recordOutbound({
193
+ chat_id: CHAT,
194
+ thread_id: null,
195
+ message_ids: [42],
196
+ texts: [ACK],
197
+ ts: 1_000_001,
198
+ })
199
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
200
+ aboutToOpen: true,
201
+ hasCrossTurnGate: true,
202
+ openedAt,
203
+ })
204
+ // Ack is below the substantive floor → not counted → the feed still opens.
205
+ expect(crossTurnAnswerDelivered).toBe(false)
206
+ expect(
207
+ mayOpenActivityCard({
208
+ producer: 'tool',
209
+ finalAnswerEverDelivered: false,
210
+ labeledToolCount: 1,
211
+ crossTurnAnswerDelivered,
212
+ }),
213
+ ).toBe(true)
214
+ })
215
+
216
+ it('an answer that PREDATES the obligation does not suppress (cutoff is the obligation openedAt)', () => {
217
+ const openedAt = 1_000_002 * 1000 // ms — obligation raised AFTER the reply
218
+ recordOutbound({
219
+ chat_id: CHAT,
220
+ thread_id: null,
221
+ message_ids: [42],
222
+ texts: [SUBSTANTIVE],
223
+ ts: 1_000_001, // sec — BEFORE openedAt
224
+ })
225
+ // The reply predates the obligation → it is not evidence THIS obligation was
226
+ // answered → the represent surface is allowed (no false suppression).
227
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
228
+ aboutToOpen: true,
229
+ hasCrossTurnGate: true,
230
+ openedAt,
231
+ })
232
+ expect(crossTurnAnswerDelivered).toBe(false)
233
+ })
234
+
235
+ it('is inert on a normal foreground turn (no cross-turn gate) even with a prior substantive answer', () => {
236
+ const openedAt = 1_000_000 * 1000
237
+ recordOutbound({
238
+ chat_id: CHAT,
239
+ thread_id: null,
240
+ message_ids: [42],
241
+ texts: [SUBSTANTIVE],
242
+ ts: 1_000_001,
243
+ })
244
+ // A foreground turn has NO cross-turn gate, so the gateway never computes the
245
+ // history check (hasCrossTurnGate=false) → the flag is false → its own card
246
+ // opens, governed only by the per-turn lever-1 latch.
247
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
248
+ aboutToOpen: true,
249
+ hasCrossTurnGate: false, // foreground turn: crossTurnGate undefined
250
+ openedAt,
251
+ })
252
+ expect(crossTurnAnswerDelivered).toBe(false)
253
+ expect(
254
+ mayOpenActivityCard({
255
+ producer: 'tool',
256
+ finalAnswerEverDelivered: false,
257
+ labeledToolCount: 1,
258
+ crossTurnAnswerDelivered,
259
+ }),
260
+ ).toBe(true)
261
+ })
262
+
263
+ it('originTurnId-keyed: an UNRELATED foreground turn does NOT consume a gate armed for another obligation — its card opens even after a substantive answer', () => {
264
+ const openedAt = 1_000_000 * 1000 // ms — some obligation raised
265
+ // A substantive answer landed in this chat since that obligation's openedAt.
266
+ recordOutbound({
267
+ chat_id: CHAT,
268
+ thread_id: null,
269
+ message_ids: [42],
270
+ texts: [SUBSTANTIVE],
271
+ ts: 1_000_001,
272
+ })
273
+
274
+ const gate = new Map<string, { sinceMs: number }>()
275
+ // obligationSweep armed the gate for obligation whose origin was message 100.
276
+ const armedOriginTurnId = deriveTurnIdMirror(CHAT, null, 100)!
277
+ armGate(gate, armedOriginTurnId, openedAt)
278
+
279
+ // The MATCHING represent turn reconstructs the same id (reuses message 100)
280
+ // → consumes the gate → carries it.
281
+ const representTurnId = deriveTurnIdMirror(CHAT, null, 100)!
282
+ expect(representTurnId).toBe(armedOriginTurnId)
283
+
284
+ // But FIRST an unrelated foreground turn (different message 200, same chat)
285
+ // enqueues. Under the old chat/thread keying its statusKey would collide with
286
+ // the armed gate and wrongly consume it. Under originTurnId keying its derived
287
+ // turn id differs → no entry → it carries NO gate.
288
+ const foregroundTurnId = deriveTurnIdMirror(CHAT, null, 200)!
289
+ expect(foregroundTurnId).not.toBe(armedOriginTurnId)
290
+ const foregroundConsumed = consumeGate(gate, foregroundTurnId)
291
+ expect(foregroundConsumed).toBeUndefined()
292
+
293
+ // → the foreground turn has no cross-turn gate → its card OPENS even though a
294
+ // ≥200-char answer landed since the OTHER obligation's openedAt (correct: that
295
+ // answer is not evidence THIS unrelated turn was already answered).
296
+ const fgCrossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
297
+ aboutToOpen: true,
298
+ hasCrossTurnGate: foregroundConsumed != null,
299
+ openedAt,
300
+ })
301
+ expect(fgCrossTurnAnswerDelivered).toBe(false)
302
+ expect(
303
+ mayOpenActivityCard({
304
+ producer: 'tool',
305
+ finalAnswerEverDelivered: false,
306
+ labeledToolCount: 1,
307
+ crossTurnAnswerDelivered: fgCrossTurnAnswerDelivered,
308
+ }),
309
+ ).toBe(true)
310
+
311
+ // The armed gate is still intact (the foreground turn did NOT eat it), so the
312
+ // matching represent turn can still consume it and suppress ITS card.
313
+ const representConsumed = consumeGate(gate, representTurnId)
314
+ expect(representConsumed).toEqual({ sinceMs: openedAt })
315
+ const reCrossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
316
+ aboutToOpen: true,
317
+ hasCrossTurnGate: representConsumed != null,
318
+ openedAt,
319
+ })
320
+ expect(reCrossTurnAnswerDelivered).toBe(true)
321
+ expect(
322
+ mayOpenActivityCard({
323
+ producer: 'tool',
324
+ finalAnswerEverDelivered: false,
325
+ labeledToolCount: 1,
326
+ crossTurnAnswerDelivered: reCrossTurnAnswerDelivered,
327
+ }),
328
+ ).toBe(false)
329
+
330
+ // Consume-once: the gate is now drained.
331
+ expect(gate.size).toBe(0)
332
+ })
333
+
334
+ it('thread-scoped: an answer in a DIFFERENT topic does not suppress a represent in this topic', () => {
335
+ const openedAt = 1_000_000 * 1000
336
+ // Substantive answer landed in thread 5, but this obligation is in thread 9.
337
+ recordOutbound({
338
+ chat_id: CHAT,
339
+ thread_id: 5,
340
+ message_ids: [42],
341
+ texts: [SUBSTANTIVE],
342
+ ts: 1_000_001,
343
+ })
344
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
345
+ aboutToOpen: true,
346
+ hasCrossTurnGate: true,
347
+ openedAt,
348
+ threadId: 9,
349
+ })
350
+ expect(crossTurnAnswerDelivered).toBe(false)
351
+ })
352
+ })
353
+
354
+ /**
355
+ * PR-4b verdict-equivalence — the CORE flag-parity proof at the pure layer.
356
+ *
357
+ * `computeFeedOpenVerdict` (the function the emission-authority façade calls in
358
+ * its enabled branch) MUST return exactly the same `mayOpen` a DIRECT
359
+ * `mayOpenActivityCard(...)` call would — over the full cross-product of every
360
+ * input that feeds the OPEN decision, against the REAL history DB. If these ever
361
+ * diverge, flag-ON would emit a different card set than flag-OFF, which is the
362
+ * one thing 4b must not do. `isOpen` must equal `activityMessageId != null`.
363
+ */
364
+ describe('computeFeedOpenVerdict ≡ direct mayOpenActivityCard (full cross-product, real history)', () => {
365
+ const deps: FeedOpenGateDeps = {
366
+ hasOutboundDeliveredSince,
367
+ historyEnabled: true,
368
+ finalAnswerMinChars: FINAL_ANSWER_MIN_CHARS,
369
+ }
370
+ const OPENED_AT_MS = 1_000_000 * 1000
371
+
372
+ // crossTurnAnswerDelivered is driven by the DB row, not a raw boolean, so we
373
+ // seed each value: a substantive row (since openedAt) → true; no row → false.
374
+ for (const crossTurnTrue of [false, true]) {
375
+ for (const finalAnswerEverDelivered of [false, true]) {
376
+ for (const producer of ['narrative', 'tool', 'liveness'] as FeedOpenProducer[]) {
377
+ for (const labeledToolCount of [0, 1]) {
378
+ for (const activityMessageId of [null, 123] as (number | null)[]) {
379
+ const label =
380
+ `crossTurn=${crossTurnTrue} final=${finalAnswerEverDelivered} ` +
381
+ `producer=${producer} tools=${labeledToolCount} ` +
382
+ `msgId=${activityMessageId ?? 'null'}`
383
+ it(`matches direct mayOpenActivityCard — ${label}`, () => {
384
+ if (crossTurnTrue) {
385
+ // Seed a substantive answer AFTER the obligation openedAt so the
386
+ // real predicate returns true (only meaningful when about to OPEN
387
+ // on a gated cross-turn surface — exercised below).
388
+ recordOutbound({
389
+ chat_id: CHAT,
390
+ thread_id: null,
391
+ message_ids: [42],
392
+ texts: [SUBSTANTIVE],
393
+ ts: 1_000_001,
394
+ })
395
+ }
396
+ // A cross-turn surface always carries the gate; the helper's own
397
+ // `activityMessageId == null` conjunct gates it to the OPEN case.
398
+ const view: FeedOpenGateView = {
399
+ activityMessageId,
400
+ finalAnswerEverDelivered,
401
+ labeledToolCount,
402
+ crossTurnGate: { sinceMs: OPENED_AT_MS },
403
+ sessionChatId: CHAT,
404
+ sessionThreadId: null,
405
+ }
406
+ const verdict = computeFeedOpenVerdict(view, producer, deps)
407
+ // Recompute the cross-turn flag the SAME way the helper does, then
408
+ // call mayOpenActivityCard directly — the oracle.
409
+ const crossTurnAnswerDelivered = realComputeCrossTurnAnswerDelivered(view, deps)
410
+ const direct = mayOpenActivityCard({
411
+ producer,
412
+ finalAnswerEverDelivered,
413
+ labeledToolCount,
414
+ crossTurnAnswerDelivered,
415
+ })
416
+ expect(verdict.mayOpen).toBe(direct)
417
+ expect(verdict.isOpen).toBe(activityMessageId != null)
418
+ })
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+ })
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Tests for demo-mask.ts — the PII masking behind the `demo` suffix on
3
+ * Telegram status commands. Pure functions with a per-process stable map;
4
+ * the determinism + distinctness + shape contracts are what callers rely on.
5
+ */
6
+ import { describe, it, expect, beforeEach } from 'vitest';
7
+ import {
8
+ maskEmail,
9
+ maskUsername,
10
+ maskVaultKey,
11
+ __resetDemoMaskCachesForTest,
12
+ } from '../demo-mask.js';
13
+
14
+ beforeEach(() => {
15
+ __resetDemoMaskCachesForTest();
16
+ });
17
+
18
+ describe('maskEmail', () => {
19
+ it('is deterministic — same input maps to the same output', () => {
20
+ const first = maskEmail('alice@realcorp.com');
21
+ const second = maskEmail('alice@realcorp.com');
22
+ expect(second).toBe(first);
23
+ });
24
+
25
+ it('maps distinct inputs to distinct outputs', () => {
26
+ const a = maskEmail('alice@realcorp.com');
27
+ const b = maskEmail('bob@realcorp.com');
28
+ const c = maskEmail('carol@realcorp.com');
29
+ expect(new Set([a, b, c]).size).toBe(3);
30
+ });
31
+
32
+ it('produces an email-shaped fake on the reserved example.com domain', () => {
33
+ const masked = maskEmail('alice@realcorp.com');
34
+ expect(masked).toMatch(/^[^@\s]+@example\.com$/);
35
+ expect(masked).not.toContain('realcorp');
36
+ });
37
+
38
+ it('never echoes the real address', () => {
39
+ const real = 'someone.private@acme.io';
40
+ expect(maskEmail(real)).not.toBe(real);
41
+ expect(maskEmail(real)).not.toContain('acme');
42
+ });
43
+
44
+ it('assigns the fixed pool in order from a clean cache', () => {
45
+ expect(maskEmail('one@x.com')).toBe('ada@example.com');
46
+ expect(maskEmail('two@x.com')).toBe('grace@example.com');
47
+ expect(maskEmail('three@x.com')).toBe('linus@example.com');
48
+ });
49
+
50
+ it('falls back to a stable distinct form past the pool', () => {
51
+ const seen = new Set<string>();
52
+ for (let i = 0; i < 15; i++) seen.add(maskEmail(`user${i}@x.com`));
53
+ // 15 distinct inputs → 15 distinct masked emails (pool of 10 + overflow).
54
+ expect(seen.size).toBe(15);
55
+ // The overflow form is still example.com-shaped.
56
+ for (const m of seen) expect(m).toMatch(/^[^@\s]+@example\.com$/);
57
+ });
58
+
59
+ it('masks a non-email label to a stable fake email too', () => {
60
+ const a = maskEmail('work-account');
61
+ const b = maskEmail('work-account');
62
+ expect(a).toBe(b);
63
+ expect(a).toMatch(/^[^@\s]+@example\.com$/);
64
+ });
65
+ });
66
+
67
+ describe('maskUsername', () => {
68
+ it('is deterministic for a @handle input', () => {
69
+ expect(maskUsername('@ken_real')).toBe(maskUsername('@ken_real'));
70
+ });
71
+
72
+ it('is deterministic for a numeric id input', () => {
73
+ expect(maskUsername('12345')).toBe(maskUsername('12345'));
74
+ });
75
+
76
+ it('maps the first distinct input to @demo_user', () => {
77
+ expect(maskUsername('@ken_real')).toBe('@demo_user');
78
+ });
79
+
80
+ it('maps a second distinct input to @demo_user2', () => {
81
+ maskUsername('@ken_real');
82
+ expect(maskUsername('12345')).toBe('@demo_user2');
83
+ });
84
+
85
+ it('always yields a @handle shape and never echoes the real id', () => {
86
+ const masked = maskUsername('@ken_real');
87
+ expect(masked).toMatch(/^@demo_user\d*$/);
88
+ expect(masked).not.toContain('ken_real');
89
+ });
90
+
91
+ it('normalises a numeric id to a @handle (not a raw number)', () => {
92
+ expect(maskUsername('12345')).toMatch(/^@demo_user\d*$/);
93
+ });
94
+ });
95
+
96
+ describe('maskVaultKey', () => {
97
+ it('is deterministic — same key maps to the same masked name', () => {
98
+ expect(maskVaultKey('coolify/api-token')).toBe(maskVaultKey('coolify/api-token'));
99
+ });
100
+
101
+ it('maps distinct keys to distinct masked names', () => {
102
+ const a = maskVaultKey('coolify/api-token');
103
+ const b = maskVaultKey('github/token');
104
+ expect(a).not.toBe(b);
105
+ });
106
+
107
+ it('keeps the namespace/key shape and hides the real service', () => {
108
+ const masked = maskVaultKey('coolify/api-token');
109
+ expect(masked).toMatch(/^demo\/secret-\d+$/);
110
+ expect(masked).not.toContain('coolify');
111
+ });
112
+
113
+ it('assigns demo/secret-N in order from a clean cache', () => {
114
+ expect(maskVaultKey('coolify/api-token')).toBe('demo/secret-1');
115
+ expect(maskVaultKey('github/token')).toBe('demo/secret-2');
116
+ expect(maskVaultKey('fatsecret/client_id')).toBe('demo/secret-3');
117
+ });
118
+ });
119
+
120
+ describe('cross-category isolation', () => {
121
+ it('keeps each category in its own namespace', () => {
122
+ // Identical raw text in different categories must not collide caches.
123
+ expect(maskEmail('x')).toMatch(/@example\.com$/);
124
+ expect(maskUsername('x')).toMatch(/^@demo_user/);
125
+ expect(maskVaultKey('x')).toMatch(/^demo\/secret-/);
126
+ });
127
+ });