switchroom 0.18.3 → 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 (101) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +2 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +119 -74
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +37 -8
  8. package/package.json +1 -1
  9. package/skills/switchroom-release/SKILL.md +78 -0
  10. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +922 -459
  12. package/telegram-plugin/gateway/gateway.ts +260 -21
  13. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  14. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  15. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  16. package/telegram-plugin/operator-events.ts +21 -0
  17. package/telegram-plugin/pending-work-progress.ts +42 -7
  18. package/telegram-plugin/quota-bar-format.ts +360 -0
  19. package/telegram-plugin/subagent-watcher.ts +27 -9
  20. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  21. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  22. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  23. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  24. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  25. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  26. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  27. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  28. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  29. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  30. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  31. package/telegram-plugin/worker-activity-feed.ts +220 -15
  32. package/profiles/default/CLAUDE.md +0 -116
  33. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  34. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  35. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  36. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  37. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  38. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  39. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  41. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  42. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  43. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  44. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  45. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  46. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  47. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  48. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  49. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  50. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  51. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  53. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  54. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  55. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  56. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  57. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  58. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  59. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  60. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  62. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  63. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  64. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  65. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  66. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  67. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  68. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  69. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  70. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  72. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  74. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  75. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  78. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  84. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  85. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  87. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  89. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  91. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  93. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  95. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  97. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  99. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ buildPersonDirectory,
4
+ resolvePersonName,
5
+ runPersonDirectoryBootCheck,
6
+ safeResolvePersonName,
7
+ type PersonDirectory,
8
+ type RawPersonEntry,
9
+ } from '../gateway/resolve-person.js'
10
+
11
+ /**
12
+ * Telegram person_id name resolution (docs/configuration.md).
13
+ *
14
+ * Covers the requirements from the converged design:
15
+ * - happy-path resolution
16
+ * - unresolved-id fallback (fail-open)
17
+ * - a malformed single entry is dropped WITHOUT affecting others
18
+ * - chat-scoping: a name is shown only in a chat/group the person is a
19
+ * member of (per that chat's allowFrom), never broadcast into every
20
+ * chat
21
+ * - boot-time validation alert firing on bad config
22
+ * - validation-step-crash alert firing (dead-man's-switch)
23
+ */
24
+
25
+ const LISA: RawPersonEntry = { key: 'lisa', person_id: 'Lisa', telegram_ids: ['8201250670'] }
26
+ const KEN: RawPersonEntry = { key: 'ken', person_id: 'Ken', telegram_ids: ['mekenthompson', '111'] }
27
+
28
+ describe('buildPersonDirectory — happy path', () => {
29
+ it('resolves a single well-formed entry', () => {
30
+ const { directory, dropped } = buildPersonDirectory([LISA])
31
+ expect(dropped).toEqual([])
32
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
33
+ })
34
+
35
+ it('resolves multiple entries, multiple telegram_ids each, case/@-insensitive', () => {
36
+ const { directory, dropped } = buildPersonDirectory([LISA, KEN])
37
+ expect(dropped).toEqual([])
38
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
39
+ expect(directory.byTelegramKey['mekenthompson']?.personId).toBe('Ken')
40
+ expect(directory.byTelegramKey['111']?.personId).toBe('Ken')
41
+ // username lookups are normalized: leading "@" stripped, lowercased
42
+ expect(directory.byTelegramKey['@MeKenThompson'.trim().replace(/^@/, '').toLowerCase()]?.personId).toBe('Ken')
43
+ })
44
+
45
+ it('no entries → empty directory (fleet behaves as today)', () => {
46
+ const { directory, dropped } = buildPersonDirectory([])
47
+ expect(directory.byTelegramKey).toEqual({})
48
+ expect(dropped).toEqual([])
49
+ })
50
+ })
51
+
52
+ describe('buildPersonDirectory — per-entry validation drops only the bad entry', () => {
53
+ it('drops an entry with an empty person_id, keeps the other entry intact', () => {
54
+ const bad: RawPersonEntry = { key: 'ghost', person_id: '', telegram_ids: ['999'] }
55
+ const { directory, dropped } = buildPersonDirectory([LISA, bad])
56
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
57
+ expect(directory.byTelegramKey['999']).toBeUndefined()
58
+ expect(dropped).toHaveLength(1)
59
+ expect(dropped[0]?.key).toBe('ghost')
60
+ expect(dropped[0]?.reason).toMatch(/person_id/)
61
+ })
62
+
63
+ it('drops an entry with no telegram_ids, keeps the other entry intact', () => {
64
+ const bad: RawPersonEntry = { key: 'ghost', person_id: 'Ghost', telegram_ids: [] }
65
+ const { directory, dropped } = buildPersonDirectory([LISA, bad])
66
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
67
+ expect(dropped).toHaveLength(1)
68
+ expect(dropped[0]?.key).toBe('ghost')
69
+ expect(dropped[0]?.reason).toMatch(/telegram_ids/)
70
+ })
71
+
72
+ it('drops a duplicate person_id (later entry loses), keeps the first entry intact', () => {
73
+ const dupe: RawPersonEntry = { key: 'lisa2', person_id: 'Lisa', telegram_ids: ['555'] }
74
+ const { directory, dropped } = buildPersonDirectory([LISA, dupe])
75
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
76
+ expect(directory.byTelegramKey['555']).toBeUndefined()
77
+ expect(dropped).toHaveLength(1)
78
+ expect(dropped[0]?.key).toBe('lisa2')
79
+ expect(dropped[0]?.reason).toMatch(/duplicate person_id/)
80
+ })
81
+
82
+ it('drops a duplicate telegram_id claimed by a different person_id (later entry loses), keeps the first entry intact — no silent last-write-wins', () => {
83
+ const dupe: RawPersonEntry = { key: 'imposter', person_id: 'Imposter', telegram_ids: ['8201250670'] }
84
+ const { directory, dropped } = buildPersonDirectory([LISA, dupe])
85
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
86
+ expect(dropped).toHaveLength(1)
87
+ expect(dropped[0]?.key).toBe('imposter')
88
+ expect(dropped[0]?.reason).toMatch(/duplicate telegram_id/)
89
+ })
90
+
91
+ it('telegram_id collision on boot check fires the config-warning alert path, does not blank the whole directory', () => {
92
+ const dupe: RawPersonEntry = { key: 'imposter', person_id: 'Imposter', telegram_ids: ['8201250670'] }
93
+ const result = runPersonDirectoryBootCheck(() => [LISA, KEN, dupe])
94
+ expect(result.alertDetail).not.toBeNull()
95
+ expect(result.alertDetail).toContain('imposter')
96
+ expect(result.directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
97
+ expect(result.directory.byTelegramKey['mekenthompson']?.personId).toBe('Ken')
98
+ })
99
+
100
+ // ── #2957 honesty fix: broadcast alertDetail must not surface the
101
+ // person_id human-name value (the display path chat-scopes names;
102
+ // the config-warning card fans out to EVERY allowFrom chat, incl.
103
+ // groups where the named person may not be a member). The verbose
104
+ // reason stays in the private logLine. ─────────────────────────
105
+ it('duplicate person_id: alertDetail scrubs the person_id value (keeps key + class); logLine keeps it', () => {
106
+ const dupe: RawPersonEntry = { key: 'lisa2', person_id: 'Lisa', telegram_ids: ['555'] }
107
+ const result = runPersonDirectoryBootCheck(() => [LISA, dupe])
108
+ expect(result.alertDetail).not.toBeNull()
109
+ // Broadcast-safe: names the losing config key + the collision class,
110
+ // but NOT the human-name "Lisa" value nor which name it collided with.
111
+ expect(result.alertDetail).toContain('lisa2')
112
+ expect(result.alertDetail).toContain('duplicate person_id already claimed')
113
+ expect(result.alertDetail).not.toContain('"Lisa"')
114
+ // Private stderr log keeps the verbose detail.
115
+ expect(result.logLine).toContain('"Lisa"')
116
+ })
117
+
118
+ it('duplicate telegram_id: alertDetail scrubs the colliding id; logLine keeps it', () => {
119
+ const dupe: RawPersonEntry = { key: 'imposter', person_id: 'Imposter', telegram_ids: ['8201250670'] }
120
+ const result = runPersonDirectoryBootCheck(() => [LISA, dupe])
121
+ expect(result.alertDetail).not.toBeNull()
122
+ expect(result.alertDetail).toContain('imposter')
123
+ expect(result.alertDetail).toContain('duplicate telegram_id already claimed')
124
+ // The colliding telegram id value is scrubbed from the broadcast.
125
+ expect(result.alertDetail).not.toContain('8201250670')
126
+ expect(result.logLine).toContain('8201250670')
127
+ })
128
+
129
+ it('drops an entry with a missing users: map key', () => {
130
+ const bad: RawPersonEntry = { key: '', person_id: 'Nobody', telegram_ids: ['1'] }
131
+ const { directory, dropped } = buildPersonDirectory([LISA, bad])
132
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
133
+ expect(dropped).toHaveLength(1)
134
+ expect(dropped[0]?.reason).toMatch(/users: map key/)
135
+ })
136
+
137
+ it('multiple malformed entries each get their own drop reason, valid entries unaffected', () => {
138
+ const bad1: RawPersonEntry = { key: 'a', person_id: '', telegram_ids: ['1'] }
139
+ const bad2: RawPersonEntry = { key: 'b', person_id: 'B', telegram_ids: [] }
140
+ const { directory, dropped } = buildPersonDirectory([LISA, bad1, KEN, bad2])
141
+ expect(dropped).toHaveLength(2)
142
+ expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
143
+ expect(directory.byTelegramKey['mekenthompson']?.personId).toBe('Ken')
144
+ })
145
+ })
146
+
147
+ describe('resolvePersonName — unresolved id falls back (fail-open)', () => {
148
+ it('returns undefined for an id not in the directory', () => {
149
+ const { directory } = buildPersonDirectory([LISA])
150
+ const name = resolvePersonName(directory, { telegramId: '000', isDm: true })
151
+ expect(name).toBeUndefined()
152
+ })
153
+
154
+ it('returns undefined for an empty directory', () => {
155
+ const { directory } = buildPersonDirectory([])
156
+ const name = resolvePersonName(directory, { telegramId: '8201250670', isDm: true })
157
+ expect(name).toBeUndefined()
158
+ })
159
+ })
160
+
161
+ describe('resolvePersonName — chat scoping', () => {
162
+ const { directory } = buildPersonDirectory([LISA])
163
+
164
+ it('DM: always resolves (the chat IS the sender)', () => {
165
+ const name = resolvePersonName(directory, { telegramId: '8201250670', isDm: true })
166
+ expect(name).toBe('Lisa')
167
+ })
168
+
169
+ it('group: resolves when the sender is present in that chat\'s allowFrom', () => {
170
+ const name = resolvePersonName(directory, {
171
+ telegramId: '8201250670',
172
+ isDm: false,
173
+ groupAllowFrom: ['8201250670', '111'],
174
+ })
175
+ expect(name).toBe('Lisa')
176
+ })
177
+
178
+ it('group: does NOT resolve when allowFrom is empty/unset (can\'t confirm membership)', () => {
179
+ expect(resolvePersonName(directory, { telegramId: '8201250670', isDm: false })).toBeUndefined()
180
+ expect(resolvePersonName(directory, { telegramId: '8201250670', isDm: false, groupAllowFrom: [] })).toBeUndefined()
181
+ })
182
+
183
+ it('group: does NOT resolve when the sender is absent from that group\'s allowFrom (a name safe in a DM is not broadcast elsewhere)', () => {
184
+ const name = resolvePersonName(directory, {
185
+ telegramId: '8201250670',
186
+ isDm: false,
187
+ groupAllowFrom: ['111'], // some other member, not lisa
188
+ })
189
+ expect(name).toBeUndefined()
190
+ })
191
+
192
+ it('group: resolves via username when username (not id) is in allowFrom', () => {
193
+ const { directory: dir2 } = buildPersonDirectory([KEN])
194
+ const name = resolvePersonName(dir2, {
195
+ telegramId: '999999',
196
+ username: 'mekenthompson',
197
+ isDm: false,
198
+ groupAllowFrom: ['mekenthompson'],
199
+ })
200
+ expect(name).toBe('Ken')
201
+ })
202
+ })
203
+
204
+ describe('safeResolvePersonName — fail-open call-site wrapper used by handleInbound', () => {
205
+ it('happy path: resolves same as resolvePersonName when nothing throws', () => {
206
+ const { directory } = buildPersonDirectory([LISA])
207
+ const name = safeResolvePersonName(directory, { telegramId: '8201250670', isDm: true }, 'raw-fallback')
208
+ expect(name).toBe('Lisa')
209
+ })
210
+
211
+ it('unresolved id: falls back to the raw id/username, same as today\'s behavior', () => {
212
+ const { directory } = buildPersonDirectory([LISA])
213
+ const name = safeResolvePersonName(directory, { telegramId: '000', isDm: true }, 'raw-fallback')
214
+ expect(name).toBe('raw-fallback')
215
+ })
216
+
217
+ it('resolution throwing (e.g. a malformed opts shape at the call site) is swallowed — falls back to the raw id/username instead of propagating and aborting handleInbound', () => {
218
+ const { directory } = buildPersonDirectory([LISA])
219
+ // Force resolvePersonName's internal normalizeTelegramKey to throw by
220
+ // passing a non-string telegramId past the type system, simulating an
221
+ // unexpected runtime shape from the Telegram payload.
222
+ const name = safeResolvePersonName(
223
+ directory,
224
+ { telegramId: undefined as unknown as string, isDm: true },
225
+ 'raw-fallback',
226
+ )
227
+ expect(name).toBe('raw-fallback')
228
+ })
229
+
230
+ it('a directory whose lookup throws is also swallowed, not propagated', () => {
231
+ const throwingDirectory: PersonDirectory = {
232
+ byTelegramKey: new Proxy(
233
+ {},
234
+ {
235
+ get() {
236
+ throw new Error('boom: directory lookup exploded')
237
+ },
238
+ },
239
+ ),
240
+ }
241
+ const spy = vi.fn()
242
+ try {
243
+ const name = safeResolvePersonName(throwingDirectory, { telegramId: '8201250670', isDm: true }, 'raw-fallback')
244
+ expect(name).toBe('raw-fallback')
245
+ } catch (err) {
246
+ spy(err)
247
+ }
248
+ expect(spy).not.toHaveBeenCalled()
249
+ })
250
+ })
251
+
252
+ describe('runPersonDirectoryBootCheck — one-time boot validation + alerting', () => {
253
+ it('happy path: no alert, directory populated', () => {
254
+ const result = runPersonDirectoryBootCheck(() => [LISA, KEN])
255
+ expect(result.alertDetail).toBeNull()
256
+ expect(result.directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
257
+ expect(result.logLine).toMatch(/boot validation ok/)
258
+ })
259
+
260
+ it('no entries configured: no alert (this is not an error)', () => {
261
+ const result = runPersonDirectoryBootCheck(() => [])
262
+ expect(result.alertDetail).toBeNull()
263
+ expect(result.directory.byTelegramKey).toEqual({})
264
+ })
265
+
266
+ it('dropped entry at boot fires an alert (routes through the caller\'s fleet-alert path) and still resolves the good entries', () => {
267
+ const bad: RawPersonEntry = { key: 'ghost', person_id: '', telegram_ids: ['1'] }
268
+ const result = runPersonDirectoryBootCheck(() => [LISA, bad])
269
+ expect(result.alertDetail).not.toBeNull()
270
+ expect(result.alertDetail).toContain('dropped 1')
271
+ expect(result.alertDetail).toContain('ghost')
272
+ expect(result.directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
273
+ })
274
+
275
+ it('dead-man\'s-switch: readEntries throwing is caught, reported via alertDetail, and PERSON_DIRECTORY falls back to empty (fail-open)', () => {
276
+ const result = runPersonDirectoryBootCheck(() => {
277
+ throw new Error('disk read exploded')
278
+ })
279
+ expect(result.alertDetail).not.toBeNull()
280
+ expect(result.alertDetail).toContain('crashed')
281
+ // alertDetail is broadcast to every allowFrom chat (incl. groups), so
282
+ // the raw error message — which may carry a host path or other detail
283
+ // — is scrubbed from it. The full message stays in the private logLine.
284
+ expect(result.alertDetail).not.toContain('disk read exploded')
285
+ expect(result.alertDetail).toContain('see gateway stderr for detail')
286
+ expect(result.directory.byTelegramKey).toEqual({})
287
+ expect(result.logLine).toContain('CRASHED')
288
+ expect(result.logLine).toContain('disk read exploded')
289
+ })
290
+ })
@@ -190,6 +190,59 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
190
190
  expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
191
191
  })
192
192
 
193
+ it('blocks + writes retryCount=1 when an early qualifying reply is followed by an undelivered verdict (trailing-content bug repro)', () => {
194
+ // Confirmed-incident shape: (1) a background-task notification
195
+ // arrives, (2) the agent calls reply ONCE early with a stale,
196
+ // notification-bearing ack ("running now" — disable_notification
197
+ // unset, so it satisfies isFinalAnswerReply regardless of length),
198
+ // (3) the agent then does more work and writes a large substantive
199
+ // verdict as plain assistant text with NO second reply call. Pre-fix,
200
+ // the hook's "reply called at least once this turn" check allowed
201
+ // this to slip through silently — the trailing verdict never reached
202
+ // the user. Post-fix the hook must block on this shape exactly like
203
+ // the zero-reply case.
204
+ const transcript = writeTranscript(tmp, [
205
+ ENQUEUE,
206
+ reply('running now'),
207
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] } },
208
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/x' } }] } },
209
+ {
210
+ type: 'assistant',
211
+ message: { content: [{ type: 'text', text: 'Here is the actual verdict: ' + 'X'.repeat(300) }] },
212
+ },
213
+ ])
214
+ const r = runHook({
215
+ event: { session_id: 's1', transcript_path: transcript },
216
+ stateDir,
217
+ })
218
+ expect(r.status).toBe(0)
219
+ const out = JSON.parse(r.stdout)
220
+ expect(out.decision).toBe('block')
221
+ expect(out.reason).toMatch(/Send your final answer/)
222
+ const statePath = join(stateDir, 'silent-end-pending.json')
223
+ expect(existsSync(statePath)).toBe(true)
224
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
225
+ expect(state.retryCount).toBe(1)
226
+ expect(state.chatId).toBe('111')
227
+ expect(state.turnKey).toBe('111:_')
228
+ })
229
+
230
+ it('does NOT false-positive on a normal single-reply turn ending on the reply tool_use', () => {
231
+ const transcript = writeTranscript(tmp, [
232
+ ENQUEUE,
233
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check.' }] } },
234
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] } },
235
+ reply('Here is your answer.', { disable_notification: false }),
236
+ ])
237
+ const r = runHook({
238
+ event: { session_id: 's1', transcript_path: transcript },
239
+ stateDir,
240
+ })
241
+ expect(r.status).toBe(0)
242
+ expect(r.stdout.trim()).toBe('')
243
+ expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
244
+ })
245
+
193
246
  it('NO_REPLY in transcript → allow stop, no state file written', () => {
194
247
  const transcript = writeTranscript(tmp, [
195
248
  ENQUEUE,
@@ -184,6 +184,144 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
184
184
  })
185
185
  })
186
186
 
187
+ // ── "at least once" bug regression — trailing content after an early
188
+ // qualifying reply must still block ────────────────────────────────
189
+
190
+ describe('scanTurnForFinalReply — trailing undelivered content after an early qualifying reply (bug repro)', () => {
191
+ it('early notification-bearing ack + later substantive plain text → block (not "reply called once" amnesty)', () => {
192
+ // Repro of the confirmed incident: the agent (1) got a background-task
193
+ // notification, (2) called reply ONCE early with a short, stale ack —
194
+ // notification-bearing (disable_notification unset/false), which
195
+ // ALWAYS qualifies as "final" under isFinalAnswerReply regardless of
196
+ // text length — then (3) wrote a large substantive verdict as plain
197
+ // assistant text with NO second reply call. The pre-fix scan returned
198
+ // 'allow' on the first qualifying block and never looked further; the
199
+ // fix must walk the whole turn and catch the undelivered trailing text.
200
+ const text = jsonl(
201
+ ENQUEUE,
202
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'running now' }),
203
+ assistantToolUse('Bash', { command: 'ls' }),
204
+ assistantToolUse('Read', { file_path: '/tmp/x' }),
205
+ assistantText('Here is the actual verdict after investigation: ' + 'X'.repeat(300)),
206
+ )
207
+ const r = scanTurnForFinalReply(text)
208
+ expect(r.decided).toBe('block')
209
+ expect(r.reason).toBe('trailing-text-after-reply')
210
+ // turnKey/chatId must still be populated so the gateway's retry
211
+ // bookkeeping works exactly as it does for the zero-reply block path.
212
+ expect(r.chatId).toBe('111')
213
+ expect(r.turnKey).toBe('111:_')
214
+ })
215
+
216
+ it('early qualifying reply + trailing SHORT pleasantry → allow (#2956 review: substance floor, no-spam)', () => {
217
+ // A short trailing text after a delivered reply (a closer like "let me
218
+ // know if you need anything else.") is NOT a dropped answer — it's a
219
+ // pleasantry the persona prompts discourage but which must not burn
220
+ // retry budget or force a redundant second reply. Only SUBSTANTIVE
221
+ // trailing text (≥ FINAL_ANSWER_MIN_CHARS) blocks. Pre-fix this
222
+ // false-positive blocked and re-prompted a healthy turn.
223
+ const text = jsonl(
224
+ ENQUEUE,
225
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
226
+ assistantText('actually, one more thing you should know'),
227
+ )
228
+ const r = scanTurnForFinalReply(text)
229
+ expect(r.decided).toBe('allow')
230
+ })
231
+
232
+ it('early qualifying reply + trailing SUBSTANTIVE undelivered text (≥ floor) → block (at-least-once holds)', () => {
233
+ // The substance floor must NOT weaken the at-least-once guarantee for
234
+ // a real dropped answer: a long trailing verdict the model forgot to
235
+ // send still blocks. The trailing text here clears FINAL_ANSWER_MIN_CHARS.
236
+ const text = jsonl(
237
+ ENQUEUE,
238
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
239
+ assistantText('Here is the actual verdict after investigation: ' + 'X'.repeat(200)),
240
+ )
241
+ const r = scanTurnForFinalReply(text)
242
+ expect(r.decided).toBe('block')
243
+ expect(r.reason).toBe('trailing-text-after-reply')
244
+ })
245
+
246
+ it('final qualifying reply is the LAST content block → allow (healthy shape, no false positive)', () => {
247
+ const text = jsonl(
248
+ ENQUEUE,
249
+ assistantText('Let me check that.'),
250
+ assistantToolUse('Bash', { command: 'ls' }),
251
+ assistantToolUse('mcp__switchroom-telegram__reply', {
252
+ text: 'Here is your answer.',
253
+ disable_notification: false,
254
+ }),
255
+ )
256
+ const r = scanTurnForFinalReply(text)
257
+ expect(r.decided).toBe('allow')
258
+ expect(r.reason).toBe('final-reply')
259
+ })
260
+
261
+ it('turn ends on a tool_result with no trailing text at all → allow (not a normal-turn false positive)', () => {
262
+ // A turn whose very last assistant content is the delivering
263
+ // reply tool_use, followed only by a (user-role) tool_result line —
264
+ // never any further assistant text. Must not be flagged.
265
+ const text = jsonl(
266
+ ENQUEUE,
267
+ assistantToolUse('mcp__switchroom-telegram__reply', {
268
+ text: 'Done — here is the summary.',
269
+ disable_notification: false,
270
+ }),
271
+ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', content: 'ok' }] } }),
272
+ )
273
+ const r = scanTurnForFinalReply(text)
274
+ expect(r.decided).toBe('allow')
275
+ expect(r.reason).toBe('final-reply')
276
+ })
277
+
278
+ it('undelivered text sandwiched between two qualifying replies is superseded by the LATER reply → allow', () => {
279
+ const text = jsonl(
280
+ ENQUEUE,
281
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'first answer', disable_notification: false }),
282
+ assistantText('actually let me reconsider that'),
283
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'corrected final answer', disable_notification: false }),
284
+ )
285
+ const r = scanTurnForFinalReply(text)
286
+ expect(r.decided).toBe('allow')
287
+ expect(r.reason).toBe('final-reply')
288
+ })
289
+
290
+ it('explicit trailing NO_REPLY after an earlier qualifying reply overrides → allow (intentional final silence)', () => {
291
+ const text = jsonl(
292
+ ENQUEUE,
293
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'draft answer', disable_notification: false }),
294
+ assistantText('actually, scrap that, nothing more to add\nNO_REPLY'),
295
+ )
296
+ const r = scanTurnForFinalReply(text)
297
+ expect(r.decided).toBe('allow')
298
+ expect(r.reason).toBe('silent-marker-text')
299
+ })
300
+
301
+ it('reverse case: an EARLIER NO_REPLY silence marker followed by later undelivered prose → block', () => {
302
+ // Mirror image of the "trailing NO_REPLY overrides" allow-case above.
303
+ // Here the model signals silence FIRST — as plain transcript text,
304
+ // not through the reply tool — and then keeps going, writing a real
305
+ // substantive answer afterward that it never sent through `reply` or
306
+ // `stream_reply`. The NO_REPLY marker is a "deliver" event (silence is
307
+ // a valid outcome), but it must not amnesty content written AFTER it,
308
+ // same failure shape as the original "at least once" bug: a naive scan
309
+ // that stops at the FIRST qualifying delivery/silence event would
310
+ // return 'allow' here and the trailing answer would be silently
311
+ // dropped. The fix's "last delivery event, then check for trailing
312
+ // text" walk must still catch this.
313
+ const text = jsonl(
314
+ ENQUEUE,
315
+ assistantText('Nothing to report right now.\nNO_REPLY'),
316
+ assistantToolUse('Bash', { command: 'ls' }),
317
+ assistantText('Wait, actually I found something you need to know: ' + 'Y'.repeat(300)),
318
+ )
319
+ const r = scanTurnForFinalReply(text)
320
+ expect(r.decided).toBe('block')
321
+ expect(r.reason).toBe('trailing-text-after-reply')
322
+ })
323
+ })
324
+
187
325
  describe('scanTurnForFinalReply — silent-marker carve-out', () => {
188
326
  it('NO_REPLY → allow', () => {
189
327
  const text = jsonl(
@@ -575,6 +575,67 @@ describe('startSubagentWatcher', () => {
575
575
  expect(narrativeCues[0]).toContain('find the repo')
576
576
  })
577
577
 
578
+ it('narration-clobber regression: a narration cue immediately followed by its resolving tool_use in the same poll does NOT get overwritten by the tool-label cue', () => {
579
+ // Reproduces the bug: sub_agent_text ("On it...") + sub_agent_tool_use
580
+ // (Bash) land in the SAME jsonl-tail read, so both the narrative
581
+ // resolution (fireNarrativeProgress) and the tool-description
582
+ // onProgress fire within one loop iteration over `events`. Since the
583
+ // card renders replace-on-write, the tool-label call previously always
584
+ // clobbered the narration call that fired moments earlier — narration
585
+ // was staged and "SHOWN" per the dedup gate, but never actually
586
+ // visible on the pinned card. Assert only ONE onProgress cue fires for
587
+ // this tick, and it's the narration (progressLine == null), not the
588
+ // tool label.
589
+ const allCues: Array<{ progressLine?: string; latestSummary: string }> = []
590
+ const agentDir = join(tmpRoot, 'agent')
591
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
592
+ mkdirSync(subagentsDir, { recursive: true })
593
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
594
+ const h = startWatcherSync({
595
+ agentDir,
596
+ onProgress: ({ progressLine, latestSummary }) => {
597
+ allCues.push({ progressLine, latestSummary })
598
+ },
599
+ })
600
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
601
+ h.poll()
602
+ // Narration text + its resolving tool_use appended and tailed together
603
+ // in a single poll — this is the "same tick" race.
604
+ appendFileSync(
605
+ jsonlPath,
606
+ buildJSONL(subAgentAssistantText('On it. Let me find the repo.'), subAgentToolUse('Bash', 'b1')),
607
+ )
608
+ h.poll()
609
+ expect(allCues.length).toBe(1)
610
+ expect(allCues[0].progressLine).toBeUndefined()
611
+ expect(allCues[0].latestSummary).toContain('find the repo')
612
+ })
613
+
614
+ it('unregressed: two sub_agent_tool_use events with no narration between them both still show tool labels (named foreground blindspot)', () => {
615
+ // Guards against the clobber-guard fix over-suppressing: narrativeJustFired
616
+ // must be false for a tool_use that has no preceding pending narrative,
617
+ // so back-to-back tool calls (a researcher reading files with no prose)
618
+ // must both still surface a progressLine.
619
+ const toolCues: Array<string | undefined> = []
620
+ const agentDir = join(tmpRoot, 'agent')
621
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
622
+ mkdirSync(subagentsDir, { recursive: true })
623
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
624
+ const h = startWatcherSync({
625
+ agentDir,
626
+ onProgress: ({ progressLine }) => {
627
+ if (progressLine != null) toolCues.push(progressLine)
628
+ },
629
+ })
630
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
631
+ h.poll()
632
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
633
+ h.poll()
634
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b2')))
635
+ h.poll()
636
+ expect(toolCues.length).toBe(2)
637
+ })
638
+
578
639
  it('narrative gate: trailing narration at turn_end is SHOWN', () => {
579
640
  const narrativeCues: string[] = []
580
641
  const agentDir = join(tmpRoot, 'agent')
@@ -96,6 +96,25 @@ describe('classifyRejection — benign Telegram 400s', () => {
96
96
  )
97
97
  expect(classifyRejection(err)).toBe('log_only')
98
98
  })
99
+ it('returns "log_only" for "group chat was upgraded to a supergroup chat" (marko 2026-07-09, recurring 2026-06-07/2026-06-09)', () => {
100
+ // A send targeted a basic-group chat_id that Telegram had since
101
+ // migrated to a supergroup (new -100xxxxxxxxxx id). This crashed the
102
+ // ENTIRE gateway process (every agent/chat) over a single stale
103
+ // cached chat_id — the same root cause recurred three times across a
104
+ // month because the crash-on-leak behavior masked the underlying
105
+ // stale-id bug instead of just logging it. A migrated/invalid
106
+ // destination chat must never take down the whole gateway.
107
+ const err = grammyError(
108
+ 400,
109
+ 'Bad Request: group chat was upgraded to a supergroup chat',
110
+ )
111
+ expect(classifyRejection(err)).toBe('log_only')
112
+ })
113
+
114
+ it('returns "log_only" for "group chat was deactivated"', () => {
115
+ const err = grammyError(400, 'Bad Request: group chat was deactivated')
116
+ expect(classifyRejection(err)).toBe('log_only')
117
+ })
99
118
  })
100
119
 
101
120
  describe('classifyRejection — genuine errors still crash', () => {