switchroom 0.15.44 → 0.16.4

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 +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  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/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3249 -1241
  10. package/dist/cli/ui/index.html +1 -1
  11. package/dist/host-control/main.js +2833 -355
  12. package/dist/vault/approvals/kernel-server.js +7482 -7439
  13. package/dist/vault/broker/server.js +11315 -11272
  14. package/examples/minimal.yaml +1 -0
  15. package/examples/switchroom.yaml +1 -0
  16. package/package.json +3 -3
  17. package/profiles/_base/start.sh.hbs +88 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  19. package/profiles/default/CLAUDE.md.hbs +3 -22
  20. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  21. package/telegram-plugin/answer-stream-flag.ts +12 -49
  22. package/telegram-plugin/answer-stream.ts +5 -150
  23. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  24. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  25. package/telegram-plugin/context-exhaustion.ts +12 -0
  26. package/telegram-plugin/demo-mask.ts +154 -0
  27. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  28. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  29. package/telegram-plugin/dist/server.js +215 -172
  30. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  31. package/telegram-plugin/draft-stream.ts +47 -410
  32. package/telegram-plugin/final-answer-detect.ts +17 -12
  33. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  34. package/telegram-plugin/format.ts +56 -19
  35. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  36. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  37. package/telegram-plugin/gateway/auth-command.ts +70 -14
  38. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  39. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  40. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  41. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  42. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  43. package/telegram-plugin/gateway/effort-command.ts +8 -3
  44. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  45. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  46. package/telegram-plugin/gateway/gateway.ts +1837 -291
  47. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  48. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  49. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  50. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  51. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  52. package/telegram-plugin/history.ts +33 -11
  53. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  54. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  55. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  56. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  57. package/telegram-plugin/issues-card.ts +4 -0
  58. package/telegram-plugin/model-unavailable.ts +124 -0
  59. package/telegram-plugin/narrative-dedup.ts +69 -0
  60. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  61. package/telegram-plugin/package.json +3 -3
  62. package/telegram-plugin/pending-work-progress.ts +12 -0
  63. package/telegram-plugin/permission-rule.ts +32 -5
  64. package/telegram-plugin/permission-title.ts +152 -9
  65. package/telegram-plugin/quota-check.ts +13 -0
  66. package/telegram-plugin/quota-watch.ts +135 -7
  67. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  68. package/telegram-plugin/registry/turns-schema.ts +9 -0
  69. package/telegram-plugin/runtime-metrics.ts +13 -0
  70. package/telegram-plugin/session-tail.ts +96 -11
  71. package/telegram-plugin/silence-poke.ts +170 -24
  72. package/telegram-plugin/slot-banner-driver.ts +3 -0
  73. package/telegram-plugin/status-no-truncate.ts +44 -0
  74. package/telegram-plugin/status-reactions.ts +20 -3
  75. package/telegram-plugin/stream-controller.ts +4 -23
  76. package/telegram-plugin/stream-reply-handler.ts +6 -24
  77. package/telegram-plugin/streaming-metrics.ts +91 -0
  78. package/telegram-plugin/subagent-watcher.ts +212 -66
  79. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  80. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  81. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  82. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  83. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  84. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  85. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  86. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  87. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  88. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  89. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  90. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  91. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  92. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  93. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  94. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  95. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  96. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  97. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  98. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  99. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  100. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  101. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  102. package/telegram-plugin/tests/history.test.ts +60 -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,340 @@
1
+ /**
2
+ * Property / invariant proof for the turn-liveness primitive (#2527).
3
+ *
4
+ * This is the ANTI-WHACK-A-MOLE test: instead of one scenario per bug, it
5
+ * fuzzes the turn-shape space (timing × tool churn × reply pattern × role ×
6
+ * NESTING × surface) and asserts the primitive's invariants hold for ANY
7
+ * shape — so a turn type nobody enumerated is covered by construction.
8
+ *
9
+ * It drives the REAL `silence-poke` wiring (startTurn → ticks → floorState →
10
+ * onMidTurnFloor → fire-once latch + clock reset) plus the pure terminal
11
+ * decision, and checks each fired floor against an independent oracle.
12
+ *
13
+ * Invariants asserted across the corpus:
14
+ * I1 liveness: the floor fires exactly when the oracle says it should
15
+ * (a user turn working silently past the floor threshold,
16
+ * below the 300s fallback window, undelivered).
17
+ * I2 fire-once: at most one floor beat per turn.
18
+ * I3 role gate: a system/cron turn never fires a floor beat.
19
+ * I4 delivered: a turn that delivered before any silent window fires none.
20
+ * I5 terminal: terminal reason = decideTerminalReason(role, delivered).
21
+ * I6 surface parity: the SAME shape on a DM (threadId null) and a forum
22
+ * topic (threadId set) produces identical floor + terminal
23
+ * outcomes — parity by construction, not by duplicated code.
24
+ */
25
+
26
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
27
+ import {
28
+ startTurn,
29
+ noteOutbound,
30
+ noteToolStart,
31
+ noteToolEnd,
32
+ pokeFloorNow,
33
+ __tickForTests,
34
+ __setDepsForTests,
35
+ __resetAllForTests,
36
+ type SilencePokeMetric,
37
+ type MidTurnFloorContext,
38
+ } from '../silence-poke.js'
39
+ import {
40
+ decideTerminalReason,
41
+ type LoopRole,
42
+ } from '../turn-liveness-floor.js'
43
+
44
+ const ORIGINAL_KILL = process.env.SWITCHROOM_DISABLE_SILENCE_POKE
45
+ const ORIGINAL_FLOOR_KILL = process.env.SWITCHROOM_TG_LIVENESS_FLOOR
46
+
47
+ const FLOOR_MS = 45_000
48
+ const FALLBACK_MS = 300_000
49
+ const TICK_MS = 5_000
50
+
51
+ /** Deterministic LCG so the corpus is reproducible (Math.random would make
52
+ * failures un-repeatable). */
53
+ function makeRng(seed: number): () => number {
54
+ let s = seed >>> 0
55
+ return () => {
56
+ s = (s * 1664525 + 1013904223) >>> 0
57
+ return s / 0x1_0000_0000
58
+ }
59
+ }
60
+
61
+ type Surface = 'dm' | 'topic'
62
+ interface Ev { t: number; kind: 'tool_start' | 'tool_end' | 'reply_substantive' | 'reply_ack' }
63
+ interface Shape {
64
+ role: LoopRole
65
+ events: Ev[]
66
+ totalMs: number
67
+ }
68
+
69
+ /** Generate a random turn shape — varied timing, churn, reply pattern, role,
70
+ * and nesting (overlapping tool calls model nested/parallel sub-agents). */
71
+ function genShape(rng: () => number): Shape {
72
+ const roleRoll = rng()
73
+ // mostly user (the case that owes liveness), some system, some sub-agent.
74
+ const role: LoopRole = roleRoll < 0.7 ? 'user' : roleRoll < 0.85 ? 'system' : 'sub-agent'
75
+ const totalMs = 10_000 + Math.floor(rng() * 600_000) // up to 10 min
76
+ const events: Ev[] = []
77
+
78
+ // Tool churn — 0..8 (possibly overlapping → nesting / fan-out).
79
+ const nTools = Math.floor(rng() * 9)
80
+ for (let i = 0; i < nTools; i++) {
81
+ const start = Math.floor(rng() * totalMs)
82
+ const dur = 1_000 + Math.floor(rng() * 120_000)
83
+ events.push({ t: start, kind: 'tool_start' })
84
+ events.push({ t: Math.min(totalMs, start + dur), kind: 'tool_end' })
85
+ }
86
+
87
+ // Reply pattern — none / one ack / one substantive / ack-then-substantive.
88
+ const replyRoll = rng()
89
+ if (replyRoll < 0.25) {
90
+ // none
91
+ } else if (replyRoll < 0.5) {
92
+ events.push({ t: Math.floor(rng() * totalMs), kind: 'reply_ack' })
93
+ } else if (replyRoll < 0.8) {
94
+ events.push({ t: Math.floor(rng() * totalMs), kind: 'reply_substantive' })
95
+ } else {
96
+ const a = Math.floor(rng() * totalMs)
97
+ events.push({ t: a, kind: 'reply_ack' })
98
+ events.push({ t: Math.min(totalMs, a + Math.floor(rng() * 60_000)), kind: 'reply_substantive' })
99
+ }
100
+ events.sort((x, y) => x.t - y.t)
101
+ return { role, events, totalMs }
102
+ }
103
+
104
+ interface RunResult {
105
+ floors: MidTurnFloorContext[]
106
+ /** oracle fire times (independent reference model). */
107
+ oracleFires: number[]
108
+ deliveredAtEnd: boolean
109
+ terminal: 'done' | 'undelivered'
110
+ }
111
+
112
+ /** Run one shape on one surface through the REAL silence-poke + an oracle. */
113
+ function runShape(shape: Shape, surface: Surface): RunResult {
114
+ const threadId = surface === 'topic' ? 99 : null
115
+ const key = threadId == null ? 'chat' : `chat:${threadId}`
116
+
117
+ // Sim state (also feeds the oracle).
118
+ let delivered = false
119
+ let inFlight = 0
120
+ let lastOutbound: number | null = null
121
+ let oracleFloorFired = false
122
+ const oracleFires: number[] = []
123
+ const floors: MidTurnFloorContext[] = []
124
+ const emitted: SilencePokeMetric[] = []
125
+
126
+ __setDepsForTests({
127
+ emitMetric: (e) => emitted.push(e),
128
+ onFrameworkFallback: () => { /* not under test here */ },
129
+ isLegitimatelyWorking: () => inFlight > 0,
130
+ floorState: () => ({ role: shape.role, finalAnswerDelivered: delivered }),
131
+ onMidTurnFloor: (ctx) => { floors.push(ctx) },
132
+ thresholdsMs: { fallback: FALLBACK_MS, floor: FLOOR_MS },
133
+ })
134
+
135
+ startTurn(key, 0)
136
+
137
+ let evIdx = 0
138
+ const applyEventsUpTo = (t: number) => {
139
+ while (evIdx < shape.events.length && shape.events[evIdx].t <= t) {
140
+ const ev = shape.events[evIdx++]
141
+ switch (ev.kind) {
142
+ case 'tool_start':
143
+ inFlight++
144
+ noteToolStart(key, `tool-${evIdx}`, 'Bash', 'doing a thing', ev.t)
145
+ break
146
+ case 'tool_end':
147
+ if (inFlight > 0) inFlight--
148
+ noteToolEnd(key, `tool-${evIdx}`, ev.t)
149
+ break
150
+ case 'reply_ack':
151
+ noteOutbound(key, ev.t)
152
+ lastOutbound = ev.t
153
+ break
154
+ case 'reply_substantive':
155
+ noteOutbound(key, ev.t)
156
+ lastOutbound = ev.t
157
+ delivered = true
158
+ break
159
+ }
160
+ }
161
+ }
162
+
163
+ for (let t = 0; t <= shape.totalMs; t += TICK_MS) {
164
+ applyEventsUpTo(t)
165
+ // Oracle: mirror the floor gate against the same pre-tick state.
166
+ const silence = t - (lastOutbound ?? 0)
167
+ if (
168
+ !oracleFloorFired &&
169
+ shape.role === 'user' &&
170
+ !delivered &&
171
+ inFlight > 0 &&
172
+ silence >= FLOOR_MS &&
173
+ silence < FALLBACK_MS
174
+ ) {
175
+ oracleFloorFired = true
176
+ oracleFires.push(t)
177
+ }
178
+ __tickForTests(t)
179
+ }
180
+ applyEventsUpTo(shape.totalMs)
181
+
182
+ return {
183
+ floors,
184
+ oracleFires,
185
+ deliveredAtEnd: delivered,
186
+ terminal: decideTerminalReason({ enabled: true, role: shape.role, finalAnswerDelivered: delivered }),
187
+ }
188
+ }
189
+
190
+ beforeEach(() => {
191
+ __resetAllForTests()
192
+ delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
193
+ delete process.env.SWITCHROOM_TG_LIVENESS_FLOOR
194
+ })
195
+ afterEach(() => {
196
+ __resetAllForTests()
197
+ if (ORIGINAL_KILL != null) process.env.SWITCHROOM_DISABLE_SILENCE_POKE = ORIGINAL_KILL
198
+ else delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
199
+ if (ORIGINAL_FLOOR_KILL != null) process.env.SWITCHROOM_TG_LIVENESS_FLOOR = ORIGINAL_FLOOR_KILL
200
+ else delete process.env.SWITCHROOM_TG_LIVENESS_FLOOR
201
+ })
202
+
203
+ describe('turn-liveness primitive — fuzzed invariants (#2527)', () => {
204
+ it('holds the invariants across 2000 random turn shapes × both surfaces', () => {
205
+ const rng = makeRng(0xC0FFEE)
206
+ const N = 2000
207
+ for (let i = 0; i < N; i++) {
208
+ const shape = genShape(rng)
209
+ const dm = runShape(shape, 'dm')
210
+ // Reset between surface runs (same module, fresh state).
211
+ __resetAllForTests()
212
+ const topic = runShape(shape, 'topic')
213
+ __resetAllForTests()
214
+
215
+ const ctx = `shape#${i} role=${shape.role} events=${JSON.stringify(shape.events)}`
216
+
217
+ // I2 — fire-once per turn.
218
+ expect(dm.floors.length, `I2 fire-once dm ${ctx}`).toBeLessThanOrEqual(1)
219
+ expect(topic.floors.length, `I2 fire-once topic ${ctx}`).toBeLessThanOrEqual(1)
220
+
221
+ // I3 — a non-user role never fires a floor beat.
222
+ if (shape.role !== 'user') {
223
+ expect(dm.floors.length, `I3 role-gate ${ctx}`).toBe(0)
224
+ expect(topic.floors.length, `I3 role-gate ${ctx}`).toBe(0)
225
+ }
226
+
227
+ // I1 + I4 — the floor fires exactly when the oracle says (which encodes
228
+ // "user + working + silent ≥ floor + < fallback + undelivered").
229
+ expect(dm.floors.length, `I1/I4 oracle-count dm ${ctx}`).toBe(dm.oracleFires.length)
230
+ if (dm.oracleFires.length === 1) {
231
+ expect(dm.floors[0].silenceMs, `I1 oracle-time dm ${ctx}`).toBeGreaterThanOrEqual(FLOOR_MS)
232
+ expect(dm.floors[0].silenceMs, `I1 oracle-time dm ${ctx}`).toBeLessThan(FALLBACK_MS)
233
+ }
234
+
235
+ // I5 — terminal honesty: undelivered user turn → 'undelivered', else 'done'.
236
+ const expectedTerminal = shape.role === 'user' && !dm.deliveredAtEnd ? 'undelivered' : 'done'
237
+ expect(dm.terminal, `I5 terminal ${ctx}`).toBe(expectedTerminal)
238
+
239
+ // I6 — surface parity: identical floor count + terminal on DM vs topic.
240
+ expect(topic.floors.length, `I6 parity floor-count ${ctx}`).toBe(dm.floors.length)
241
+ expect(topic.terminal, `I6 parity terminal ${ctx}`).toBe(dm.terminal)
242
+ // The fired beat must route to the right surface.
243
+ if (topic.floors.length === 1) {
244
+ expect(topic.floors[0].threadId, `I6 parity thread-route ${ctx}`).toBe(99)
245
+ }
246
+ if (dm.floors.length === 1) {
247
+ expect(dm.floors[0].threadId, `I6 parity dm-route ${ctx}`).toBeNull()
248
+ }
249
+ }
250
+ })
251
+
252
+ it('the documented #2527 case: 6-min busy-silent user turn fires exactly one floor beat', () => {
253
+ // role=user, a long tool stretch, no reply — the overlord-on-marko case.
254
+ const shape: Shape = {
255
+ role: 'user',
256
+ events: [
257
+ { t: 2_000, kind: 'tool_start' },
258
+ { t: 360_000, kind: 'tool_end' },
259
+ ],
260
+ totalMs: 365_000,
261
+ }
262
+ const dm = runShape(shape, 'dm')
263
+ __resetAllForTests()
264
+ const topic = runShape(shape, 'topic')
265
+
266
+ expect(dm.floors.length).toBe(1)
267
+ expect(dm.floors[0].silenceMs).toBeGreaterThanOrEqual(FLOOR_MS)
268
+ expect(dm.terminal).toBe('undelivered') // never delivered → not a false 👍
269
+ // Parity: the supergroup topic behaves identically.
270
+ expect(topic.floors.length).toBe(1)
271
+ expect(topic.floors[0].threadId).toBe(99)
272
+ expect(topic.terminal).toBe('undelivered')
273
+ })
274
+
275
+ it('a cron turn that works silently never fires the floor and keeps 👍', () => {
276
+ const shape: Shape = {
277
+ role: 'system',
278
+ events: [
279
+ { t: 1_000, kind: 'tool_start' },
280
+ { t: 200_000, kind: 'tool_end' },
281
+ ],
282
+ totalMs: 210_000,
283
+ }
284
+ const r = runShape(shape, 'dm')
285
+ expect(r.floors.length).toBe(0)
286
+ expect(r.terminal).toBe('done')
287
+ })
288
+ })
289
+
290
+ describe('pokeFloorNow — "Status?" mid-turn short-circuit (#2527)', () => {
291
+ function setup(role: LoopRole, delivered: boolean) {
292
+ const floors: MidTurnFloorContext[] = []
293
+ let working = false
294
+ __setDepsForTests({
295
+ emitMetric: () => {},
296
+ onFrameworkFallback: () => {},
297
+ isLegitimatelyWorking: () => working,
298
+ floorState: () => ({ role, finalAnswerDelivered: delivered }),
299
+ onMidTurnFloor: (ctx) => { floors.push(ctx) },
300
+ thresholdsMs: { fallback: FALLBACK_MS, floor: FLOOR_MS },
301
+ })
302
+ return { floors, setWorking: (w: boolean) => { working = w } }
303
+ }
304
+
305
+ it('fires immediately for a user turn even before the threshold and with no tool in flight', () => {
306
+ const { floors } = setup('user', false)
307
+ startTurn('chat', 0)
308
+ pokeFloorNow('chat', 3_000) // 3s in, not working — the user explicitly asked
309
+ expect(floors.length).toBe(1)
310
+ expect(floors[0].forced).toBe(true)
311
+ })
312
+
313
+ it('is fire-once: a second "Status?" within the same turn is a no-op', () => {
314
+ const { floors } = setup('user', false)
315
+ startTurn('chat', 0)
316
+ pokeFloorNow('chat', 3_000)
317
+ pokeFloorNow('chat', 6_000)
318
+ expect(floors.length).toBe(1)
319
+ })
320
+
321
+ it('does nothing for a system turn (role gate holds even when forced)', () => {
322
+ const { floors } = setup('system', false)
323
+ startTurn('chat', 0)
324
+ pokeFloorNow('chat', 3_000)
325
+ expect(floors.length).toBe(0)
326
+ })
327
+
328
+ it('does nothing once a substantive answer has been delivered', () => {
329
+ const { floors } = setup('user', true)
330
+ startTurn('chat', 0)
331
+ pokeFloorNow('chat', 3_000)
332
+ expect(floors.length).toBe(0)
333
+ })
334
+
335
+ it('is a no-op when there is no live turn for the key', () => {
336
+ const { floors } = setup('user', false)
337
+ pokeFloorNow('chat', 3_000) // no startTurn
338
+ expect(floors.length).toBe(0)
339
+ })
340
+ })
@@ -173,6 +173,29 @@ describe("statusPairedText", () => {
173
173
  expect(out).toContain("Status: <code>running</code> · up 3h 12m");
174
174
  });
175
175
 
176
+ // demo mode (the `/status demo` suffix) — masks the paired-user tag only.
177
+ describe("demo mode", () => {
178
+ it("WITHOUT demo, the real handle still renders", () => {
179
+ expect(statusPairedText({ user: "@ken_real", meta })).toContain("Paired as @ken_real.");
180
+ });
181
+ it("WITH demo, the handle is masked to a @demo_user form", () => {
182
+ const out = statusPairedText({ user: "@ken_real", meta, demo: true });
183
+ expect(out).not.toContain("@ken_real");
184
+ expect(out).toMatch(/Paired as @demo_user\d*\./);
185
+ });
186
+ it("WITH demo, a numeric sender id is masked to a @handle, not a raw number", () => {
187
+ const out = statusPairedText({ user: "12345", meta, demo: true });
188
+ expect(out).not.toContain("12345");
189
+ expect(out).toMatch(/Paired as @demo_user\d*\./);
190
+ });
191
+ it("WITH demo, the agent/model/auth topology is NOT masked", () => {
192
+ const out = statusPairedText({ user: "@ken_real", meta, demo: true });
193
+ // Out-of-scope fields stay real.
194
+ expect(out).toContain("Auth: ✓ Max · expires 29 days");
195
+ expect(out).toContain("<code>sonnet</code>");
196
+ });
197
+ });
198
+
176
199
  // Issue #142 PR 3 — audit details surfaced on /status when the gateway
177
200
  // successfully loads switchroom.yaml. Pre-#142 this content lived in
178
201
  // the SessionStart greeting card; now it's pulled on demand.
@@ -439,10 +462,16 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
439
462
  ).not.toMatch(/<code>\/reauth\b/);
440
463
  });
441
464
 
442
- it("menu is short enough for a mobile keyboard (<= 20 entries)", () => {
465
+ it("menu is short enough for a mobile keyboard (<= 21 entries)", () => {
443
466
  // Hard cap: Telegram autocomplete on mobile shows ~8-10 commands
444
- // without scrolling. 20 is a generous upper bound.
445
- expect(TELEGRAM_MENU_COMMANDS.length).toBeLessThanOrEqual(20);
467
+ // without scrolling. 21 is a generous upper bound (well under
468
+ // Telegram's own 100-command limit). /whoami brought it to 21.
469
+ expect(TELEGRAM_MENU_COMMANDS.length).toBeLessThanOrEqual(21);
470
+ });
471
+
472
+ it("menu includes /whoami (sandbox introspection)", () => {
473
+ const names = TELEGRAM_MENU_COMMANDS.map(c => c.command);
474
+ expect(names, "missing /whoami from Telegram menu").toContain("whoami");
446
475
  });
447
476
 
448
477
  it("every menu command is documented in switchroomHelpText", () => {