switchroom 0.17.10 → 0.18.6

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 (149) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +29 -2
  3. package/dist/auth-broker/index.js +6163 -152
  4. package/dist/cli/notion-write-pretool.mjs +31 -3
  5. package/dist/cli/switchroom.js +695 -526
  6. package/dist/host-control/main.js +6184 -173
  7. package/dist/vault/approvals/kernel-server.js +5893 -165
  8. package/dist/vault/broker/server.js +6666 -921
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +2 -2
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/switchroom-release/SKILL.md +78 -0
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -25
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +740 -106
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  25. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  26. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  27. package/telegram-plugin/operator-events.ts +21 -0
  28. package/telegram-plugin/package.json +6 -0
  29. package/telegram-plugin/pending-work-progress.ts +42 -7
  30. package/telegram-plugin/quota-bar-format.ts +360 -0
  31. package/telegram-plugin/quota-watch.ts +4 -6
  32. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  33. package/telegram-plugin/registry/turns-schema.ts +78 -0
  34. package/telegram-plugin/render/ir.ts +209 -0
  35. package/telegram-plugin/render/parse.ts +363 -0
  36. package/telegram-plugin/render/render.ts +440 -0
  37. package/telegram-plugin/render/rich-render.ts +72 -0
  38. package/telegram-plugin/stream-controller.ts +14 -3
  39. package/telegram-plugin/subagent-watcher.ts +27 -9
  40. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  41. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  42. package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
  43. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  44. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  45. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  46. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  47. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  48. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  49. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  50. package/telegram-plugin/tests/model-command.test.ts +193 -16
  51. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  52. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  53. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  54. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  55. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  57. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  58. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  59. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  60. package/telegram-plugin/tests/render/render.test.ts +436 -0
  61. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  62. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  63. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  64. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  65. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  66. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  67. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  68. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  69. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  70. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  71. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  72. package/telegram-plugin/worker-activity-feed.ts +220 -15
  73. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  74. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  75. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  76. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  78. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  79. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
  80. package/profiles/default/CLAUDE.md +0 -116
  81. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  82. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  126. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * liveness-tracker.test.ts — drives the REAL LivenessTracker (the testability
3
+ * seam of the orphaned-reply thinking-pause fix).
4
+ *
5
+ * Root cause being fixed: the orphaned-reply fuse (ORPHANED_REPLY_TIMEOUT_MS =
6
+ * 30 s) was reset ONLY by `tool_label` / `text` events. During a long model
7
+ * reasoning pause the gateway sees NO such events (thinking carries no text),
8
+ * so the fuse ran down and force-ended a genuinely-live turn mid-work.
9
+ *
10
+ * The fix stamps `lastStreamEventAt` on ANY genuine stream event; if a genuine
11
+ * event landed within `windowMs` (default 120 s) the turn is "recently
12
+ * streaming" and the fuse re-arms instead of firing. A genuine multi-minute
13
+ * hang (no events at all) still fires.
14
+ *
15
+ * These tests use the REAL class — no replica helpers.
16
+ *
17
+ * NOTE on onStreamEvent's signature: it is `(kind, durationMs, now)`. The
18
+ * tracker is time-only — it never reads text content (the "Prompt is too long"
19
+ * marker matters to the gateway's context-exhaustion detection, not to the
20
+ * tracker). Where the fix spec wrote `onStreamEvent('text','Prompt is too
21
+ * long',0)` we pass `undefined` as durationMs (it is not a turn_end).
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest'
25
+ import {
26
+ LivenessTracker,
27
+ ORPHANED_REPLY_STREAM_WINDOW_MS,
28
+ ORPHANED_REPLY_TIMEOUT_MS,
29
+ ORPHANED_REPLY_MAX_REARMS,
30
+ } from '../context-exhaustion.js'
31
+
32
+ const W = ORPHANED_REPLY_STREAM_WINDOW_MS // 120_000
33
+ const MAX = ORPHANED_REPLY_MAX_REARMS // 20
34
+ const FUSE = ORPHANED_REPLY_TIMEOUT_MS // 30_000
35
+
36
+ function expiry(
37
+ t: LivenessTracker,
38
+ now: number,
39
+ opts?: { working?: boolean; humanWaiting?: boolean },
40
+ ) {
41
+ return t.decideOnExpiry({
42
+ working: opts?.working ?? false,
43
+ humanWaiting: opts?.humanWaiting ?? false,
44
+ now,
45
+ windowMs: W,
46
+ maxRearms: MAX,
47
+ })
48
+ }
49
+
50
+ describe('LivenessTracker — pinned constants', () => {
51
+ it('window is 120 s and fuse tick is 30 s', () => {
52
+ expect(W).toBe(120_000)
53
+ expect(FUSE).toBe(30_000)
54
+ expect(MAX).toBe(20)
55
+ })
56
+ })
57
+
58
+ describe('T1 — thinking-pause survival', () => {
59
+ it('text→tool_use→tool_result then a 45s stream gap keeps re-arming (gap < window)', () => {
60
+ const t = new LivenessTracker(0)
61
+ t.onStreamEvent('text', undefined, 0)
62
+ t.onStreamEvent('tool_use', undefined, 1_000)
63
+ t.onStreamEvent('tool_result', undefined, 2_000)
64
+
65
+ // +30s past the last event (t=32s): recently streaming → re-arm.
66
+ const d1 = expiry(t, 32_000)
67
+ expect(d1).toEqual({ rearm: true, countsAgainstCap: true })
68
+ expect(t.orphanedReplyRearmCount).toBe(1)
69
+
70
+ // +45s past the last event (t=47s): still within the 120s window → re-arm.
71
+ const d2 = expiry(t, 47_000)
72
+ expect(d2).toEqual({ rearm: true, countsAgainstCap: true })
73
+ expect(t.orphanedReplyRearmCount).toBe(2)
74
+ })
75
+
76
+ it('the next genuine stream event ZEROES the rearm counter and re-stamps liveness', () => {
77
+ const t = new LivenessTracker(0)
78
+ t.onStreamEvent('tool_result', undefined, 2_000)
79
+ expiry(t, 32_000)
80
+ expiry(t, 47_000)
81
+ expect(t.orphanedReplyRearmCount).toBe(2)
82
+
83
+ t.onStreamEvent('tool_label', undefined, 48_000)
84
+ expect(t.orphanedReplyRearmCount).toBe(0)
85
+ expect(t.lastStreamEventAt).toBe(48_000)
86
+ })
87
+
88
+ it('a synthetic turn_end (durationMs === -1) does NOT stamp or reset (F4 boundary)', () => {
89
+ const t = new LivenessTracker(0)
90
+ t.onStreamEvent('tool_label', undefined, 48_000)
91
+ expiry(t, 60_000) // counter → 1
92
+ expect(t.orphanedReplyRearmCount).toBe(1)
93
+
94
+ t.onStreamEvent('turn_end', -1, 70_000)
95
+ expect(t.lastStreamEventAt).toBe(48_000) // unchanged — synthetic excluded
96
+ expect(t.orphanedReplyRearmCount).toBe(1) // not reset
97
+
98
+ // A REAL turn_end (durationMs >= 0) DOES stamp — harmless, the turn is
99
+ // ending anyway (spec #3).
100
+ t.onStreamEvent('turn_end', 0, 71_000)
101
+ expect(t.lastStreamEventAt).toBe(71_000)
102
+ expect(t.orphanedReplyRearmCount).toBe(0)
103
+ })
104
+ })
105
+
106
+ describe('T2 — production repro (48s tool_result, 78s expiry)', () => {
107
+ it('text@0, tool_use@1s, tool_result@48s → expiry@78s re-arms (78-48=30 < 120)', () => {
108
+ const t = new LivenessTracker(0)
109
+ t.onStreamEvent('text', undefined, 0)
110
+ t.onStreamEvent('tool_use', undefined, 1_000)
111
+ t.onStreamEvent('tool_result', undefined, 48_000)
112
+
113
+ const d = expiry(t, 78_000)
114
+ expect(d.rearm).toBe(true)
115
+ expect(t.orphanedReplyRearmCount).toBe(1)
116
+
117
+ // tool_label@80s zeroes the counter (progress observed).
118
+ t.onStreamEvent('tool_label', undefined, 80_000)
119
+ expect(t.orphanedReplyRearmCount).toBe(0)
120
+ })
121
+ })
122
+
123
+ describe('T3 — long active turn (A2: counter reset is load-bearing)', () => {
124
+ it('tool_label every 60s + expiry every 60s+30s NEVER fires across 15 min; counter never exceeds 1', () => {
125
+ const t = new LivenessTracker(0)
126
+ for (let k = 0; k < 15; k++) {
127
+ t.onStreamEvent('tool_label', undefined, k * 60_000) // progress → reset
128
+ const d = expiry(t, k * 60_000 + 30_000, { working: true })
129
+ expect(d.rearm).toBe(true) // never fires
130
+ expect(t.orphanedReplyRearmCount).toBeLessThanOrEqual(1)
131
+ }
132
+ })
133
+
134
+ it('WITHOUT the progress reset the SAME cadence hits cap=20 near t=600s (proves reset load-bearing)', () => {
135
+ const t = new LivenessTracker(0)
136
+ let firedAt = -1
137
+ for (let k = 1; k <= 25; k++) {
138
+ const now = k * 30_000
139
+ // working:true so it would re-arm indefinitely IF the counter didn't
140
+ // accumulate — but with no onStreamEvent reset it climbs to the cap.
141
+ const d = expiry(t, now, { working: true })
142
+ if (!d.rearm) {
143
+ firedAt = now
144
+ break
145
+ }
146
+ }
147
+ // 20th re-arm lands at t=600s; the 21st expiry (t=630s) fires at the cap.
148
+ expect(t.orphanedReplyRearmCount).toBe(MAX)
149
+ expect(firedAt).toBeGreaterThanOrEqual(600_000)
150
+ })
151
+ })
152
+
153
+ describe('T4 — genuine hang caught (no events at all)', () => {
154
+ it('tool_result@0 then silence: re-arms via recentlyStreaming @30/60/90s, FIRES @120s (window lapsed)', () => {
155
+ const t = new LivenessTracker(0)
156
+ t.onStreamEvent('tool_result', undefined, 0)
157
+
158
+ for (const now of [30_000, 60_000, 90_000]) {
159
+ const d = expiry(t, now) // working:false — kept alive only by recentlyStreaming
160
+ expect(d.rearm).toBe(true)
161
+ }
162
+
163
+ // t=120s: now - lastStreamEventAt = 120_000 >= window → not recently
164
+ // streaming, working false → fail-safe FIRE.
165
+ const d = expiry(t, 120_000)
166
+ expect(d).toEqual({ rearm: false, countsAgainstCap: false })
167
+ })
168
+ })
169
+
170
+ describe('T5 — context-exhaustion recovery COMPLETES (not suppressed forever)', () => {
171
+ it('a "Prompt is too long" text event stamps liveness; defensive-guard predicate is true@30s, false@>=120s', () => {
172
+ const t = new LivenessTracker(0)
173
+ // The marker is a genuine `text` event (content is not a tracker concern).
174
+ t.onStreamEvent('text', undefined, 0)
175
+
176
+ // Context exhaustion means no tool is in flight: working === false. The
177
+ // defensive-guard suppression predicate is `working || recentlyStreaming`.
178
+ const working = false
179
+
180
+ // @30s: recentlyStreaming true → synthetic turn_end is suppressed. This
181
+ // DOCUMENTS the accepted ~30s→~120s recovery-latency growth (F3).
182
+ expect(working || t.recentlyStreaming(30_000, W)).toBe(true)
183
+
184
+ // @120s: window lapsed → NOT suppressed → teardown COMPLETES.
185
+ expect(working || t.recentlyStreaming(120_000, W)).toBe(false)
186
+
187
+ // @150s: still not suppressed — recovery is delayed, never forever.
188
+ expect(working || t.recentlyStreaming(150_000, W)).toBe(false)
189
+ })
190
+ })
191
+
192
+ describe('T7 — cap semantics', () => {
193
+ it('interleaved expiry/genuine-event: count 1 → 0 → 1 → 2', () => {
194
+ const t = new LivenessTracker(0)
195
+
196
+ expect(expiry(t, 10_000).rearm).toBe(true)
197
+ expect(t.orphanedReplyRearmCount).toBe(1)
198
+
199
+ t.onStreamEvent('tool_label', undefined, 11_000) // genuine event resets
200
+ expect(t.orphanedReplyRearmCount).toBe(0)
201
+
202
+ expiry(t, 20_000)
203
+ expect(t.orphanedReplyRearmCount).toBe(1)
204
+
205
+ expiry(t, 30_000) // consecutive silence (last event 11_000, gap 19s < 120s)
206
+ expect(t.orphanedReplyRearmCount).toBe(2)
207
+ })
208
+
209
+ it('21 consecutive recently-streaming expiries (no reset): 21st fires at the cap', () => {
210
+ const t = new LivenessTracker(0)
211
+ let last: { rearm: boolean; countsAgainstCap: boolean } | undefined
212
+ for (let k = 1; k <= 21; k++) {
213
+ // now = k*1000 keeps the gap from the seed (0) under the 120s window,
214
+ // so recentlyStreaming stays true throughout — the ONLY bound is the cap.
215
+ last = expiry(t, k * 1_000)
216
+ }
217
+ expect(last).toEqual({ rearm: false, countsAgainstCap: true })
218
+ expect(t.orphanedReplyRearmCount).toBe(MAX)
219
+
220
+ // humanWaiting past the cap → uncapped re-arm.
221
+ const dHuman = expiry(t, 22_000, { humanWaiting: true })
222
+ expect(dHuman).toEqual({ rearm: true, countsAgainstCap: false })
223
+
224
+ // recentlyStreaming false + working false + humanWaiting false → fail-safe FIRE.
225
+ const dFire = expiry(t, 200_000)
226
+ expect(dFire).toEqual({ rearm: false, countsAgainstCap: false })
227
+ })
228
+ })
@@ -38,6 +38,7 @@ function okResult(output: string): InjectResult {
38
38
  function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
39
39
  const calls: Array<{ agent: string; command: string }> = [];
40
40
  const restartCalls: string[] = [];
41
+ const relaunchCalls: Array<{ model: string; reason: string }> = [];
41
42
  const deps: ModelCommandDeps = {
42
43
  inject: async (agent, command) => {
43
44
  calls.push({ agent, command });
@@ -50,9 +51,10 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
50
51
  preBlock: (s) => `<pre>${s}</pre>`,
51
52
  getActiveSessionModel: () => null,
52
53
  scheduleRestart: async (reason) => { restartCalls.push(reason); },
54
+ scheduleModelRelaunch: async (model, reason) => { relaunchCalls.push({ model, reason }); },
53
55
  ...overrides,
54
56
  };
55
- return { deps, calls, restartCalls };
57
+ return { deps, calls, restartCalls, relaunchCalls };
56
58
  }
57
59
 
58
60
  describe("parseModelCommand", () => {
@@ -111,8 +113,17 @@ describe("isValidModelArg", () => {
111
113
  expect(isValidModelArg(good), good).toBe(true);
112
114
  }
113
115
  });
116
+ it("accepts OpenRouter-style sr-vendor/model ids (embedded slash)", () => {
117
+ // `/` is a legal char in a model id — needed for OpenRouter-style
118
+ // `sr-vendor/model` routing. It is NOT a shell metachar inside the
119
+ // double-quoted `claude --model "$_EFFECTIVE_MODEL"` launch, so it is
120
+ // safe. Kept aligned with the shell shape gate in profiles/_base/start.sh.hbs.
121
+ for (const good of ["sr-openrouter/gpt-5", "sr-vendor/model", "sr-mistralai/mixtral-8x7b"]) {
122
+ expect(isValidModelArg(good), good).toBe(true);
123
+ }
124
+ });
114
125
  it("rejects whitespace, metacharacters, and over-long strings", () => {
115
- for (const bad of ["", " ", "a b", "a;b", "a/b", "-x", "a".repeat(120), "a\tb", "a\nb"]) {
126
+ for (const bad of ["", " ", "a b", "a;b", "-x", "a".repeat(120), "a\tb", "a\nb", "/leading"]) {
116
127
  expect(isValidModelArg(bad), JSON.stringify(bad)).toBe(false);
117
128
  }
118
129
  });
@@ -355,16 +366,6 @@ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
355
366
  expect(reply.text).toContain("Set model to sonnet");
356
367
  });
357
368
 
358
- it("does NOT restart when switching from Claude to sr-* (no session override)", async () => {
359
- const { deps, calls, restartCalls } = makeDeps({
360
- getActiveSessionModel: () => null,
361
- });
362
- await handleModelCommand({ kind: "set", model: "sr-gemini-2.5-pro" }, deps);
363
- // sr-* is not a Claude model — no restart
364
- expect(restartCalls).toHaveLength(0);
365
- expect(calls).toHaveLength(1);
366
- });
367
-
368
369
  it("surfaces scheduleRestart failures without propagating the error", async () => {
369
370
  const { deps, calls } = makeDeps({
370
371
  getActiveSessionModel: () => "sr-deepseek-r1",
@@ -386,6 +387,128 @@ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
386
387
  });
387
388
  });
388
389
 
390
+ describe("handleModelCommand — Claude → sr-* session relaunch", () => {
391
+ it("schedules a model relaunch (carrier) instead of injecting for an sr-* target", async () => {
392
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
393
+ getActiveSessionModel: () => null,
394
+ });
395
+ const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
396
+ // Must NOT inject (claude's picker rejects sr-* ids)
397
+ expect(calls).toHaveLength(0);
398
+ // Must NOT use the sr→claude scheduleRestart path
399
+ expect(restartCalls).toHaveLength(0);
400
+ // Must schedule the carrier-based relaunch with the full sr-* id
401
+ expect(relaunchCalls).toHaveLength(1);
402
+ expect(relaunchCalls[0].model).toBe("sr-glm-5");
403
+ expect(reply.text).toContain("sr-glm-5");
404
+ expect(reply.text).toContain("30s");
405
+ expect(reply.html).toBe(true);
406
+ });
407
+
408
+ it("expands a short sr alias then relaunches on the full id", async () => {
409
+ const { deps, calls, relaunchCalls } = makeDeps({ getActiveSessionModel: () => null });
410
+ await handleModelCommand({ kind: "set", model: "glm" }, deps);
411
+ expect(calls).toHaveLength(0);
412
+ expect(relaunchCalls).toHaveLength(1);
413
+ expect(relaunchCalls[0].model).toBe("sr-glm-5");
414
+ });
415
+
416
+ it("Claude target still uses the instant inject path (no relaunch)", async () => {
417
+ const { deps, calls, relaunchCalls, restartCalls } = makeDeps({
418
+ getActiveSessionModel: () => null,
419
+ });
420
+ await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
421
+ expect(calls).toHaveLength(1);
422
+ expect(relaunchCalls).toHaveLength(0);
423
+ expect(restartCalls).toHaveLength(0);
424
+ });
425
+
426
+ it("sr-* → Claude still takes the scheduleRestart path (not scheduleModelRelaunch)", async () => {
427
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
428
+ getActiveSessionModel: () => "sr-glm-5",
429
+ });
430
+ await handleModelCommand({ kind: "set", model: "opus" }, deps);
431
+ expect(calls).toHaveLength(0);
432
+ expect(relaunchCalls).toHaveLength(0);
433
+ expect(restartCalls).toHaveLength(1);
434
+ });
435
+
436
+ it("surfaces scheduleModelRelaunch failures without propagating the error", async () => {
437
+ const { deps, calls } = makeDeps({
438
+ getActiveSessionModel: () => null,
439
+ scheduleModelRelaunch: async () => { throw new Error("carrier write failed"); },
440
+ });
441
+ const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
442
+ expect(calls).toHaveLength(0);
443
+ expect(reply.text).toContain("Could not schedule model switch");
444
+ expect(reply.text).toContain("carrier write failed");
445
+ });
446
+ });
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Manual full-sr-* passthrough (Ken 2026-07-08): the /model MENU stays curated
450
+ // to the main SR_MODEL_ALIASES set, but typing `/model <any registered sr-*>`
451
+ // must switch to that exact model — the set path is a SHAPE gate only, with NO
452
+ // whitelist against SR_MODEL_LABELS / SR_MODEL_ALIASES. These guard that any
453
+ // arbitrary sr-* id passes through verbatim and schedules the relaunch on the
454
+ // exact id (not rejected, not remapped, not injected).
455
+ // ---------------------------------------------------------------------------
456
+ describe("handleModelCommand — arbitrary sr-* passthrough (no whitelist)", () => {
457
+ // sr-* names that are NOT in SR_MODEL_ALIASES (so not menu-reachable) but ARE
458
+ // registered in the live litellm config — must still switch when typed.
459
+ const ARBITRARY_SR = [
460
+ "sr-gpt-oss-120b",
461
+ "sr-gpt-oss-20b",
462
+ "sr-minimax-m3",
463
+ "sr-gemini-flash-lite",
464
+ "sr-gpt-5.5",
465
+ "sr-gpt-5-codex",
466
+ "sr-gpt-5.2-codex",
467
+ "sr-deepseek-v4-flash",
468
+ ];
469
+
470
+ it("each arbitrary sr-* passes the shape gate and isSrModel", () => {
471
+ for (const name of ARBITRARY_SR) {
472
+ expect(isValidModelArg(name), `${name} must pass MODEL_ARG_RE`).toBe(true);
473
+ expect(isSrModel(name), `${name} must be recognised as sr-*`).toBe(true);
474
+ expect(isClaudeModel(name)).toBe(false);
475
+ }
476
+ });
477
+
478
+ it("relaunches on the exact typed id — not rejected, not remapped", async () => {
479
+ for (const name of ARBITRARY_SR) {
480
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
481
+ getActiveSessionModel: () => null,
482
+ });
483
+ const reply = await handleModelCommand({ kind: "set", model: name }, deps);
484
+ // Never injected (claude's picker rejects sr-* ids).
485
+ expect(calls, `${name} must not inject`).toHaveLength(0);
486
+ // Never the sr→claude restart path (source session is Claude here).
487
+ expect(restartCalls, `${name} must not scheduleRestart`).toHaveLength(0);
488
+ // Scheduled the carrier relaunch on the EXACT id (no alias remap).
489
+ expect(relaunchCalls).toHaveLength(1);
490
+ expect(relaunchCalls[0].model, `${name} must relaunch verbatim`).toBe(name);
491
+ expect(reply.text).toContain(name);
492
+ }
493
+ });
494
+
495
+ it("parseModelCommand accepts arbitrary sr-* ids as a set command", () => {
496
+ for (const name of ARBITRARY_SR) {
497
+ expect(parseModelCommand(`/model ${name}`)).toEqual({ kind: "set", model: name });
498
+ }
499
+ });
500
+
501
+ it("switching FROM an arbitrary sr-* back to Claude takes the restart path", async () => {
502
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
503
+ getActiveSessionModel: () => "sr-gpt-oss-120b",
504
+ });
505
+ await handleModelCommand({ kind: "set", model: "opus" }, deps);
506
+ expect(calls).toHaveLength(0);
507
+ expect(relaunchCalls).toHaveLength(0);
508
+ expect(restartCalls).toHaveLength(1);
509
+ });
510
+ });
511
+
389
512
  describe("inject allowlist contract", () => {
390
513
  it("/model stays on the inject allowlist (the set path depends on it)", async () => {
391
514
  const { INJECT_COMMANDS } = await import("../../src/agents/inject.js");
@@ -433,11 +556,13 @@ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
433
556
  }
434
557
  });
435
558
 
436
- it("handleModelCommand injects expanded sr-* id, not the short alias", async () => {
437
- const { deps, calls } = makeDeps({ getActiveSessionModel: () => null });
559
+ it("handleModelCommand relaunches on the expanded sr-* id, not the short alias", async () => {
560
+ const { deps, calls, relaunchCalls } = makeDeps({ getActiveSessionModel: () => null });
438
561
  await handleModelCommand({ kind: "set", model: "flash" }, deps);
439
- expect(calls).toHaveLength(1);
440
- expect(calls[0].command).toBe("/model sr-gemini-2.5-flash");
562
+ // sr-* targets no longer inject — they carrier-relaunch on the full id.
563
+ expect(calls).toHaveLength(0);
564
+ expect(relaunchCalls).toHaveLength(1);
565
+ expect(relaunchCalls[0].model).toBe("sr-gemini-2.5-flash");
441
566
  });
442
567
 
443
568
  it("handleModelCommand with alias schedules restart when session is on sr-*", async () => {
@@ -688,6 +813,58 @@ describe("SR_MODEL_LABELS", () => {
688
813
  expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
689
814
  expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
690
815
  });
816
+
817
+ it("bumps sr-glm-5 label to GLM-5.2 (now targets glm-5.2 in litellm)", () => {
818
+ expect(SR_MODEL_LABELS["sr-glm-5"]).toBe("GLM-5.2");
819
+ });
820
+
821
+ it("has friendly names for the new OpenRouter sr-* models (display-only)", () => {
822
+ expect(SR_MODEL_LABELS["sr-gpt-oss-20b"]).toBe("GPT-OSS 20B");
823
+ expect(SR_MODEL_LABELS["sr-gpt-oss-120b"]).toBe("GPT-OSS 120B");
824
+ expect(SR_MODEL_LABELS["sr-gpt-5.5"]).toBe("GPT-5.5");
825
+ expect(SR_MODEL_LABELS["sr-gpt-5-codex"]).toBe("GPT-5 Codex");
826
+ expect(SR_MODEL_LABELS["sr-gpt-5.2-codex"]).toBe("GPT-5.2 Codex");
827
+ expect(SR_MODEL_LABELS["sr-gemini-flash-lite"]).toBe("Gemini 3.1 Flash Lite");
828
+ expect(SR_MODEL_LABELS["sr-minimax-m3"]).toBe("MiniMax M3");
829
+ expect(SR_MODEL_LABELS["sr-deepseek-v4-flash"]).toBe("DeepSeek V4 Flash");
830
+ });
831
+ });
832
+
833
+ describe("menu stays curated — new OpenRouter models are display-only, not in the picker", () => {
834
+ // The 8 new models are typeable (manual passthrough) but must NOT bloat the
835
+ // /model keyboard. externalModelNames() seeds the picker from SR_MODEL_ALIASES
836
+ // values, so these ids must be ABSENT from both the alias table and the picker
837
+ // list. This locks in Ken's "menu = main models only" decision.
838
+ const NEW_SR = [
839
+ "sr-gpt-oss-20b",
840
+ "sr-gpt-oss-120b",
841
+ "sr-gpt-5.5",
842
+ "sr-gpt-5-codex",
843
+ "sr-gpt-5.2-codex",
844
+ "sr-gemini-flash-lite",
845
+ "sr-minimax-m3",
846
+ "sr-deepseek-v4-flash",
847
+ ];
848
+
849
+ it("SR_MODEL_ALIASES stays the curated 6-entry main set (no new short aliases)", () => {
850
+ expect(Object.keys(SR_MODEL_ALIASES).sort()).toEqual(
851
+ ["codex", "deepseek", "flash", "gemini", "glm", "r1"],
852
+ );
853
+ // None of the new sr-* ids are an alias target.
854
+ const targets = new Set(Object.values(SR_MODEL_ALIASES));
855
+ for (const name of NEW_SR) {
856
+ expect(targets.has(name), `${name} must NOT be an alias target`).toBe(false);
857
+ }
858
+ });
859
+
860
+ it("externalModelNames (picker seed) excludes the new models", () => {
861
+ const picker = externalModelNames([]);
862
+ for (const name of NEW_SR) {
863
+ expect(picker.includes(name), `${name} must NOT appear in the picker`).toBe(false);
864
+ }
865
+ // The curated main set is still exactly the alias targets.
866
+ expect(picker.sort()).toEqual([...new Set(Object.values(SR_MODEL_ALIASES))].sort());
867
+ });
691
868
  });
692
869
 
693
870
  describe("buildModelMenu — with sr-* models", () => {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * narrative-render.test.ts — T6: end-to-end narration through the REAL pure
3
+ * render helpers, tied to the REAL LivenessTracker (thinking-pause fix).
4
+ *
5
+ * Proves the narration surface still behaves after the orphaned-reply fix:
6
+ * (a) a long narration is clipped and rendered as ONE feed line;
7
+ * (b) a narration that is a draft of the delivered reply is SUPPRESSED,
8
+ * while a differing narration is SHOWN;
9
+ * (c) a narration after a >30s stream gap keeps the turn alive (the tracker
10
+ * stamps liveness) AND renders; thinking / tool_result carry no narration
11
+ * text so they add NO feed line (thinking only drives the 🤔 reaction).
12
+ *
13
+ * All assertions drive the REAL helpers — no replicas.
14
+ *
15
+ * DEVIATION NOTE: the fix spec's T6(a) wrote "≤120-char line". The real clip
16
+ * bound is `STATUS_LINE_MAX` (200) in status-no-truncate.ts, so this test pins
17
+ * the real constant instead of the stale 120.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ clipNarrative,
23
+ appendActivityLabel,
24
+ renderActivityFeedWithNested,
25
+ } from '../tool-activity-summary.js'
26
+ import { isDraftOfReply, REPLY_TOOLS } from '../narrative-dedup.js'
27
+ import { STATUS_LINE_MAX } from '../status-no-truncate.js'
28
+ import { LivenessTracker, ORPHANED_REPLY_STREAM_WINDOW_MS } from '../context-exhaustion.js'
29
+
30
+ const W = ORPHANED_REPLY_STREAM_WINDOW_MS
31
+
32
+ describe('T6(a) — long narration is clipped to one feed line', () => {
33
+ it('clipNarrative → appendActivityLabel → renderActivityFeedWithNested yields one clipped line', () => {
34
+ const long = 'x'.repeat(STATUS_LINE_MAX + 50) // 250 chars, no newlines
35
+ const clipped = clipNarrative(long)
36
+ expect(clipped.length).toBe(STATUS_LINE_MAX)
37
+
38
+ const lines: string[] = []
39
+ appendActivityLabel(lines, clipped)
40
+ expect(lines).toHaveLength(1)
41
+
42
+ const rendered = renderActivityFeedWithNested(lines, [])
43
+ expect(rendered).not.toBeNull()
44
+ expect(rendered).toContain(clipped)
45
+ })
46
+
47
+ it('clipNarrative keeps only the first line, trimmed', () => {
48
+ const multi = ' first line of narration \nsecond line should be dropped'
49
+ expect(clipNarrative(multi)).toBe('first line of narration')
50
+ })
51
+ })
52
+
53
+ describe('T6(b) — a narration that drafts the reply is suppressed', () => {
54
+ // Model the reducer decision: only render narration that is NOT a draft of
55
+ // the delivered reply (narrative-dedup gate).
56
+ function renderNarrationUnlessDraft(narration: string, replyText: string): string | null {
57
+ if (isDraftOfReply(narration, replyText)) return null // suppressed
58
+ const lines: string[] = []
59
+ appendActivityLabel(lines, clipNarrative(narration))
60
+ return renderActivityFeedWithNested(lines, [])
61
+ }
62
+
63
+ it('REPLY_TOOLS is the reply set (sanity — draft dedup only runs for reply tools)', () => {
64
+ expect(REPLY_TOOLS.has('reply')).toBe(true)
65
+ expect(REPLY_TOOLS.has('stream_reply')).toBe(true)
66
+ })
67
+
68
+ it('suppresses a trailing narration that is a draft of the delivered reply', () => {
69
+ const reply = 'The build passed and I deployed the fix to staging.'
70
+ const draft = 'The build passed and I deployed the fix to staging' // ~identical
71
+ expect(isDraftOfReply(draft, reply)).toBe(true)
72
+ expect(renderNarrationUnlessDraft(draft, reply)).toBeNull()
73
+ })
74
+
75
+ it('shows a genuinely different narration line', () => {
76
+ const reply = 'The build passed and I deployed the fix to staging.'
77
+ const working = 'Running the integration test suite'
78
+ expect(isDraftOfReply(working, reply)).toBe(false)
79
+ const rendered = renderNarrationUnlessDraft(working, reply)
80
+ expect(rendered).not.toBeNull()
81
+ expect(rendered).toContain('Running the integration test suite')
82
+ })
83
+ })
84
+
85
+ describe('T6(c) — narration after a stream gap: alive + rendered; thinking/tool_result add no line', () => {
86
+ it('a narration text event after a >30s gap stamps liveness (turn alive) and renders', () => {
87
+ const t = new LivenessTracker(0)
88
+ t.onStreamEvent('text', undefined, 0)
89
+ // >30s gap with no events — turn would previously have been at risk.
90
+ const gapNow = 35_000
91
+ // Narration lands: stamps liveness, keeping the turn alive.
92
+ t.onStreamEvent('text', undefined, gapNow)
93
+ expect(t.lastStreamEventAt).toBe(gapNow)
94
+ expect(t.recentlyStreaming(gapNow, W)).toBe(true)
95
+
96
+ const lines: string[] = []
97
+ appendActivityLabel(lines, clipNarrative('Compiling the gateway bundle'))
98
+ const rendered = renderActivityFeedWithNested(lines, [])
99
+ expect(rendered).toContain('Compiling the gateway bundle')
100
+ })
101
+
102
+ it('thinking and tool_result stamp liveness but carry NO narration text → no feed line', () => {
103
+ const t = new LivenessTracker(0)
104
+ const lines: string[] = []
105
+ // A rendered narration line already present.
106
+ appendActivityLabel(lines, clipNarrative('Editing gateway.ts'))
107
+ expect(lines).toHaveLength(1)
108
+
109
+ // thinking: keeps the turn alive (liveness stamp) but produces no text.
110
+ t.onStreamEvent('thinking', undefined, 5_000)
111
+ expect(t.lastStreamEventAt).toBe(5_000)
112
+ // A thinking/tool_result event has no label → appendActivityLabel adds nothing.
113
+ expect(appendActivityLabel(lines, undefined)).toBeNull()
114
+ expect(lines).toHaveLength(1)
115
+
116
+ // tool_result: same — stamps liveness, no narration line.
117
+ t.onStreamEvent('tool_result', undefined, 6_000)
118
+ expect(t.lastStreamEventAt).toBe(6_000)
119
+ expect(appendActivityLabel(lines, '')).toBeNull()
120
+ expect(lines).toHaveLength(1)
121
+
122
+ const rendered = renderActivityFeedWithNested(lines, [])
123
+ expect(rendered).toContain('Editing gateway.ts')
124
+ })
125
+ })
@@ -253,6 +253,21 @@ describe('renderOperatorEvent — unknown-5xx', () => {
253
253
  })
254
254
  })
255
255
 
256
+ describe('renderOperatorEvent — config-warning', () => {
257
+ it('is framed as non-urgent (dismiss-only, no restart/reauth/logs actions)', () => {
258
+ const { text, keyboard } = renderOperatorEvent(
259
+ makeEvent('config-warning', { detail: 'person_id: dropped 1 malformed users: entry at boot — lisa (empty person_id)' }),
260
+ )
261
+ expect(text).toContain('Config warning')
262
+ expect(text).toContain('Non-urgent')
263
+ const buttons = keyboard.inline_keyboard.flat()
264
+ expect(buttons.every(b => b.callback_data?.includes('dismiss'))).toBe(true)
265
+ expect(buttons.some(b => b.callback_data?.includes('restart'))).toBe(false)
266
+ expect(buttons.some(b => b.callback_data?.includes('reauth'))).toBe(false)
267
+ expect(buttons.some(b => b.callback_data?.includes('logs'))).toBe(false)
268
+ })
269
+ })
270
+
256
271
  describe('renderOperatorEvent — markdown escaping (#2669)', () => {
257
272
  it('passes < > literally in agent name (markdown, #2669)', () => {
258
273
  const { text } = renderOperatorEvent(makeEvent('unknown-4xx', { agent: '<evil>' }))
@@ -276,6 +291,7 @@ describe('renderOperatorEvent — all kinds produce valid keyboard structure', (
276
291
  'agent-restarted-unexpectedly',
277
292
  'unknown-4xx',
278
293
  'unknown-5xx',
294
+ 'config-warning',
279
295
  ]
280
296
 
281
297
  for (const kind of allKinds) {