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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +2 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +119 -74
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +37 -8
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +922 -459
- package/telegram-plugin/gateway/gateway.ts +260 -21
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- 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', () => {
|