switchroom 0.20.7 → 0.20.9

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 (52) hide show
  1. package/dist/agent-scheduler/index.js +111 -14
  2. package/dist/auth-broker/index.js +113 -30
  3. package/dist/cli/autoaccept-poll.js +5 -3
  4. package/dist/cli/drive-write-pretool.mjs +5 -3
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -3
  6. package/dist/cli/notion-write-pretool.mjs +67 -6
  7. package/dist/cli/switchroom.js +389 -31
  8. package/dist/host-control/main.js +69 -8
  9. package/dist/vault/approvals/kernel-server.js +68 -7
  10. package/dist/vault/broker/server.js +68 -7
  11. package/package.json +1 -1
  12. package/profiles/default/CLAUDE.md.hbs +12 -13
  13. package/telegram-plugin/ask-user.ts +6 -7
  14. package/telegram-plugin/bridge/ipc-client.ts +17 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +5 -2
  16. package/telegram-plugin/dist/gateway/gateway.js +410 -178
  17. package/telegram-plugin/dist/server.js +5 -2
  18. package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
  19. package/telegram-plugin/gateway/auth-command.ts +4 -2
  20. package/telegram-plugin/gateway/boot-reason.ts +61 -0
  21. package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
  22. package/telegram-plugin/gateway/cron-session.ts +66 -0
  23. package/telegram-plugin/gateway/gateway.ts +36 -34
  24. package/telegram-plugin/gateway/narrative-lane.ts +21 -1
  25. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  26. package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
  27. package/telegram-plugin/gateway/stream-render.ts +11 -2
  28. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  29. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  31. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  32. package/telegram-plugin/render/line-start-guard.ts +27 -2
  33. package/telegram-plugin/sticker-aliases.ts +12 -14
  34. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  35. package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
  36. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  37. package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
  38. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  39. package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
  40. package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
  41. package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
  42. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  43. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  44. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  45. package/telegram-plugin/tests/represent-guard.test.ts +45 -0
  46. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  47. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  48. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  49. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
  51. package/telegram-plugin/throttle-tier.ts +59 -0
  52. package/telegram-plugin/turn-flush-safety.ts +79 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * #4348 — the Tier-1 cheap-cron bridge-register drain must route through the
3
+ * shared `redeliverBufferedInbound` chokepoint so a delivered cron fire is
4
+ * `spool.ack`'d exactly once and CANNOT re-fire after a restart.
5
+ *
6
+ * The bug: `onClientRegistered`'s `<agent>-cron` branch did a raw
7
+ * `pendingInboundBuffer.drain()` + `client.send()` loop and returned early,
8
+ * never reaching `spool.ack` (which lives only inside
9
+ * `redeliverBufferedInbound`). A due cron tick spooled during the boot window
10
+ * (before the cron bridge registered) stayed live in the durable spool, so
11
+ * boot-replay re-pushed it on the next restart and the SAME fire was delivered
12
+ * a second time — a duplicate cron delivery bounded only by the 15-min
13
+ * escalation sweep.
14
+ *
15
+ * These tests assert the OUTCOME on the real drain seam
16
+ * (`drainCronBridgeOnRegister`), against a REAL spool + buffer:
17
+ * 1. the boot-window fire is delivered to the cron bridge, AND
18
+ * 2. its durable spool entry is acked (liveCount → 0), AND
19
+ * 3. a simulated restart's boot-replay finds nothing to re-push, so the fire
20
+ * does NOT re-fire.
21
+ *
22
+ * RED-before-GREEN: with the pre-#4348 raw-drain body the fire is delivered but
23
+ * the spool entry stays live, so assertions (2)+(3) fail. With the fix routing
24
+ * through `redeliverBufferedInbound` they pass.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import {
29
+ createInboundSpool,
30
+ type InboundSpoolFsSeam,
31
+ type InboundSpool,
32
+ } from '../gateway/inbound-spool.js'
33
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
34
+ import { drainCronBridgeOnRegister, cronIdentity } from '../gateway/cron-session.js'
35
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
36
+
37
+ const SPOOL_PATH = '/state/agent/telegram/inbound-spool.jsonl'
38
+
39
+ /** In-memory fake fs for the spool — models append + atomic-rename compaction.
40
+ * Shared across "restarts" so the durable JSONL survives, exactly like the
41
+ * persistent per-agent volume. */
42
+ function fakeFs(): InboundSpoolFsSeam {
43
+ const files = new Map<string, string>()
44
+ return {
45
+ appendFileSync: (p, d) => files.set(p, (files.get(p) ?? '') + d),
46
+ readFileSync: (p) => files.get(p) ?? '',
47
+ writeFileSync: (p, d) => files.set(p, d),
48
+ renameSync: (from, to) => {
49
+ files.set(to, files.get(from) ?? '')
50
+ files.delete(from)
51
+ },
52
+ existsSync: (p) => files.has(p),
53
+ statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
54
+ fsyncFileSync: () => {},
55
+ fsyncDirSync: () => {},
56
+ }
57
+ }
58
+
59
+ function cronFire(over: Partial<InboundMessage> = {}): InboundMessage {
60
+ return {
61
+ type: 'inbound',
62
+ chatId: 'c1',
63
+ messageId: 0, // synthetic — cron fires carry no Telegram messageId
64
+ user: 'system',
65
+ userId: 0,
66
+ ts: 1000,
67
+ text: 'Time for the daily digest',
68
+ meta: { source: 'cron', session: 'cron' },
69
+ ...over,
70
+ } as InboundMessage
71
+ }
72
+
73
+ /** A capturing stand-in for the just-registered cron IPC client. */
74
+ function fakeClient(agentName: string): {
75
+ agentName: string
76
+ send: (msg: unknown) => void
77
+ sent: unknown[]
78
+ } {
79
+ const sent: unknown[] = []
80
+ return { agentName, send: (m) => void sent.push(m), sent }
81
+ }
82
+
83
+ /** Simulate the gateway's boot-replay: re-push every live (un-acked) spool
84
+ * entry into a fresh in-memory buffer, exactly as gateway.ts does at boot. */
85
+ function bootReplayInto(spool: InboundSpool): ReturnType<typeof createPendingInboundBuffer> {
86
+ const buffer = createPendingInboundBuffer({ log: () => {}, spool })
87
+ for (const { agent, msg } of spool.liveEntries()) buffer.push(agent, msg)
88
+ return buffer
89
+ }
90
+
91
+ describe('#4348 cron-bridge drain routes through the spool-ack chokepoint', () => {
92
+ it('acks the boot-window cron fire and does not re-fire after restart', () => {
93
+ const fs = fakeFs()
94
+ const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
95
+ const cronAgent = cronIdentity('overlord') // "overlord-cron"
96
+
97
+ // Boot window: a due cron tick arrives BEFORE the cron bridge registers.
98
+ // It is buffered (in-memory) AND durably spooled by the same push.
99
+ const buffer = createPendingInboundBuffer({ log: () => {}, spool })
100
+ buffer.push(cronAgent, cronFire())
101
+ expect(spool.liveCount()).toBe(1) // durably recorded, not yet delivered
102
+
103
+ // The cron bridge registers → the drain seam under test runs.
104
+ const client = fakeClient(cronAgent)
105
+ const result = drainCronBridgeOnRegister(client, buffer, spool)
106
+
107
+ // (1) The fire was delivered to the cron bridge.
108
+ expect(result.drained).toBe(1)
109
+ expect(result.redelivered).toBe(1)
110
+ const deliveredFires = client.sent.filter(
111
+ (m): m is InboundMessage => (m as InboundMessage).type === 'inbound',
112
+ )
113
+ expect(deliveredFires).toHaveLength(1)
114
+ expect(deliveredFires[0]!.text).toBe('Time for the daily digest')
115
+
116
+ // (2) The durable spool entry is tombstoned — the whole point of #4348.
117
+ expect(spool.liveCount()).toBe(0)
118
+ expect(spool.liveEntries()).toHaveLength(0)
119
+
120
+ // (3) Simulated restart: boot-replay finds nothing to re-push, so the SAME
121
+ // fire does NOT re-fire. (Pre-fix this replayed the un-acked entry.)
122
+ const afterRestart = bootReplayInto(spool)
123
+ expect(afterRestart.drain(cronAgent)).toHaveLength(0)
124
+ })
125
+
126
+ it('a send failure re-buffers the fire and leaves the spool entry live (lossless)', () => {
127
+ const fs = fakeFs()
128
+ const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
129
+ const cronAgent = cronIdentity('overlord')
130
+
131
+ const buffer = createPendingInboundBuffer({ log: () => {}, spool })
132
+ buffer.push(cronAgent, cronFire())
133
+
134
+ // Client whose send throws for inbound fires (bridge wedged mid-drain).
135
+ const throwing = {
136
+ agentName: cronAgent,
137
+ send: (m: unknown) => {
138
+ if ((m as InboundMessage).type === 'inbound') throw new Error('socket gone')
139
+ },
140
+ }
141
+ const result = drainCronBridgeOnRegister(throwing, buffer, spool)
142
+
143
+ // Not delivered → re-buffered, and the spool entry stays LIVE so the next
144
+ // register (or boot-replay) retries it. Nothing is dropped, nothing acked.
145
+ expect(result.redelivered).toBe(0)
146
+ expect(result.rebuffered).toBe(1)
147
+ expect(spool.liveCount()).toBe(1)
148
+ expect(buffer.drain(cronAgent)).toHaveLength(1)
149
+ })
150
+ })
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Double-wake dedup (v0.20.8 candidate) — one background sub-agent completion
3
+ * must produce ONE parent wake, not two.
4
+ *
5
+ * The bug: a single background sub-agent completion fanned out to TWO
6
+ * independent wakes of the parent session —
7
+ * 1. the claude CLI's own `<task-notification>` (enqueued by the CLI itself;
8
+ * switchroom read it only as a background-shell liveness signal), and
9
+ * 2. the gateway-synthesized `subagent_handback` inbound (subagent-watcher
10
+ * `onFinish` → `pendingInboundBuffer.push`).
11
+ * Nothing linked them. The CLI-native wake cannot be suppressed (it is the
12
+ * CLI's internal queue), so the fix gates the ONE lever switchroom holds — the
13
+ * handback enqueue — on a recently-seen terminal `<task-notification>` for
14
+ * EXACTLY the same task/agent id (`CliTaskNotificationLedger`,
15
+ * subagent-handback-marker.ts; consumed by `decideSubagentHandback`).
16
+ *
17
+ * FAIL-OPEN contract pinned here: every uncertain path DELIVERS the handback.
18
+ * A dropped real wake (silent worker, user waits forever) is strictly worse
19
+ * than an occasional double.
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import {
24
+ CliTaskNotificationLedger,
25
+ TASK_NOTIFICATION_DEDUP_TTL_MS,
26
+ } from '../gateway/subagent-handback-marker.js'
27
+ import { decideSubagentHandback } from '../gateway/subagent-handback-inbound-builder.js'
28
+ import { projectTranscriptLine } from '../session-tail.js'
29
+ import { applyBackgroundShellLiveness } from '../gateway/background-shell-liveness.js'
30
+ import { readFileSync } from 'node:fs'
31
+ import { join, dirname } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+
34
+ const T0 = 1_700_000_000_000
35
+
36
+ const base = {
37
+ handbackEnvValue: undefined as string | undefined,
38
+ outcome: 'completed' as 'completed' | 'failed' | 'orphan',
39
+ isBackground: true,
40
+ fleetChatId: '777',
41
+ ownerChatId: '999',
42
+ taskDescription: 'Do the thing',
43
+ resultText: 'Done.',
44
+ jsonlAgentId: 'a204deeaedb27b580',
45
+ nowMs: T0,
46
+ }
47
+
48
+ // A real captured queue-operation enqueue line shape (fixture
49
+ // bg-shell-liveness-3519.jsonl), retargeted at an AGENT task id — the live
50
+ // transcripts prove `<task-id>` for a backgrounded agent equals the
51
+ // `agent-<id>.jsonl` stem the subagent-watcher uses as `agentId`
52
+ // (verified: <task-id>a204deeaedb27b580</task-id> ↔
53
+ // subagents/agent-a204deeaedb27b580.jsonl).
54
+ function notifLine(taskId: string, status: string): string {
55
+ return JSON.stringify({
56
+ type: 'queue-operation',
57
+ operation: 'enqueue',
58
+ timestamp: '2026-08-04T00:00:00.000Z',
59
+ sessionId: 'deadbeef-0000-4000-8000-000000000000',
60
+ content:
61
+ `<task-notification>\n<task-id>${taskId}</task-id>\n` +
62
+ `<tool-use-id>toolu_01TESTTESTTESTTEST</tool-use-id>\n` +
63
+ `<output-file>/tmp/tasks/${taskId}.output</output-file>\n` +
64
+ `<status>${status}</status>\n<summary>Agent done</summary>\n</task-notification>`,
65
+ })
66
+ }
67
+
68
+ describe('CliTaskNotificationLedger', () => {
69
+ it('records a terminal notification and reports it within the TTL', () => {
70
+ const l = new CliTaskNotificationLedger()
71
+ l.record('a204deeaedb27b580', 'completed', T0)
72
+ expect(l.seenRecently('a204deeaedb27b580', T0 + 5_000)).toBe(true)
73
+ expect(l.seenRecently('a204deeaedb27b580', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS)).toBe(true)
74
+ })
75
+
76
+ it('fail-open: expires after the TTL (a resumed worker second completion delivers)', () => {
77
+ const l = new CliTaskNotificationLedger()
78
+ l.record('a204deeaedb27b580', 'completed', T0)
79
+ expect(l.seenRecently('a204deeaedb27b580', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1)).toBe(false)
80
+ })
81
+
82
+ it('fail-open: a DIFFERENT task id never matches', () => {
83
+ const l = new CliTaskNotificationLedger()
84
+ l.record('a204deeaedb27b580', 'completed', T0)
85
+ expect(l.seenRecently('a999999999999999', T0 + 1)).toBe(false)
86
+ })
87
+
88
+ it('fail-open: non-terminal statuses and empty ids are never recorded', () => {
89
+ const l = new CliTaskNotificationLedger()
90
+ l.record('a204deeaedb27b580', 'running', T0)
91
+ l.record('', 'completed', T0)
92
+ expect(l.seenRecently('a204deeaedb27b580', T0 + 1)).toBe(false)
93
+ expect(l.seenRecently('', T0 + 1)).toBe(false)
94
+ })
95
+
96
+ it('records failed and killed as terminal (the CLI wakes the parent for those too)', () => {
97
+ const l = new CliTaskNotificationLedger()
98
+ l.record('aaa', 'failed', T0)
99
+ l.record('bbb', 'killed', T0)
100
+ expect(l.seenRecently('aaa', T0 + 1)).toBe(true)
101
+ expect(l.seenRecently('bbb', T0 + 1)).toBe(true)
102
+ })
103
+
104
+ it('prunes expired entries on write (bounded memory)', () => {
105
+ const l = new CliTaskNotificationLedger()
106
+ l.record('old', 'completed', T0)
107
+ l.record('new', 'completed', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_000)
108
+ // The old entry is both expired AND physically pruned; either way the
109
+ // observable contract is: it no longer suppresses.
110
+ expect(l.seenRecently('old', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_001)).toBe(false)
111
+ expect(l.seenRecently('new', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_001)).toBe(true)
112
+ })
113
+ })
114
+
115
+ describe('decideSubagentHandback × CLI task-notification dedup', () => {
116
+ // ── Outcome (a): one background completion → exactly ONE parent wake ──────
117
+ // RED-before/GREEN-after core: on pre-fix code this decision delivered a
118
+ // second wake; post-fix it is suppressed with the dedicated reason.
119
+ it('suppresses the handback when the CLI already woke the parent for this exact completion', () => {
120
+ const ledger = new CliTaskNotificationLedger()
121
+ // The CLI's wake for this completion (wake #1) is observed by the tail…
122
+ for (const ev of projectTranscriptLine(notifLine('a204deeaedb27b580', 'completed'))) {
123
+ if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
124
+ }
125
+ // …then the watcher's onFinish runs the decide gate (would-be wake #2).
126
+ const wakes: string[] = ['cli-task-notification'] // wake #1 already happened
127
+ const d = decideSubagentHandback({
128
+ ...base,
129
+ cliTaskNotificationSeen: ledger.seenRecently(base.jsonlAgentId, T0 + 2_000),
130
+ })
131
+ if (d.deliver) wakes.push('subagent_handback')
132
+ expect(d).toEqual({ deliver: false, reason: 'cli-task-notification' })
133
+ expect(wakes).toHaveLength(1) // exactly one parent wake, not two
134
+ })
135
+
136
+ it('suppresses a FAILED completion the CLI already reported, too', () => {
137
+ const d = decideSubagentHandback({
138
+ ...base,
139
+ outcome: 'failed',
140
+ cliTaskNotificationSeen: true,
141
+ })
142
+ expect(d).toEqual({ deliver: false, reason: 'cli-task-notification' })
143
+ })
144
+
145
+ // ── Outcome (b): fail-open — a handback with NO matching notification fires ─
146
+ it('fail-open: delivers when no notification was seen (flag false)', () => {
147
+ const d = decideSubagentHandback({ ...base, cliTaskNotificationSeen: false })
148
+ expect(d.deliver).toBe(true)
149
+ if (d.deliver) {
150
+ expect(d.chatId).toBe('777')
151
+ expect(d.inbound.meta?.source).toBe('subagent_handback')
152
+ }
153
+ })
154
+
155
+ it('fail-open: delivers when the flag is omitted entirely (older callers)', () => {
156
+ const d = decideSubagentHandback({ ...base })
157
+ expect(d.deliver).toBe(true)
158
+ })
159
+
160
+ it('fail-open end-to-end: a notification for a DIFFERENT agent id does not suppress', () => {
161
+ const ledger = new CliTaskNotificationLedger()
162
+ for (const ev of projectTranscriptLine(notifLine('a999999999999999', 'completed'))) {
163
+ if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
164
+ }
165
+ const d = decideSubagentHandback({
166
+ ...base,
167
+ cliTaskNotificationSeen: ledger.seenRecently(base.jsonlAgentId, T0 + 2_000),
168
+ })
169
+ expect(d.deliver).toBe(true)
170
+ })
171
+
172
+ it('fail-open end-to-end: an EXPIRED notification does not suppress', () => {
173
+ const ledger = new CliTaskNotificationLedger()
174
+ ledger.record(base.jsonlAgentId, 'completed', T0)
175
+ const d = decideSubagentHandback({
176
+ ...base,
177
+ cliTaskNotificationSeen: ledger.seenRecently(
178
+ base.jsonlAgentId,
179
+ T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1,
180
+ ),
181
+ })
182
+ expect(d.deliver).toBe(true)
183
+ })
184
+
185
+ it('gate ordering: foreground/outcome/env gates still win over the dedup reason', () => {
186
+ expect(
187
+ decideSubagentHandback({ ...base, isBackground: false, cliTaskNotificationSeen: true }),
188
+ ).toEqual({ deliver: false, reason: 'foreground' })
189
+ expect(
190
+ decideSubagentHandback({ ...base, outcome: 'orphan', cliTaskNotificationSeen: true }),
191
+ ).toEqual({ deliver: false, reason: 'outcome-not-terminal' })
192
+ expect(
193
+ decideSubagentHandback({ ...base, handbackEnvValue: '0', cliTaskNotificationSeen: true }),
194
+ ).toEqual({ deliver: false, reason: 'env-disabled' })
195
+ })
196
+ })
197
+
198
+ // ── Outcome (c): the task-notification keeps working as a liveness signal ────
199
+ // Recording into the dedup ledger is an ADDITIVE read of the same event; the
200
+ // background-shell-liveness consumer must still see the shell as DEAD.
201
+ describe('task_notification still drives background-shell liveness (not swallowed)', () => {
202
+ it('one projected event feeds BOTH the liveness registry and the dedup ledger', () => {
203
+ const dead: string[] = []
204
+ const alive: string[] = []
205
+ const registry = {
206
+ noteBackgroundShellAlive: (_k: string, id: string) => void alive.push(id),
207
+ noteBackgroundShellDead: (_k: string, id: string) => void dead.push(id),
208
+ }
209
+ const ledger = new CliTaskNotificationLedger()
210
+ const events = projectTranscriptLine(notifLine('bxa4sv3dq', 'completed'))
211
+ expect(events).toHaveLength(1)
212
+ for (const ev of events) {
213
+ applyBackgroundShellLiveness(registry, 'chat:-', ev)
214
+ if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
215
+ }
216
+ expect(dead).toEqual(['bxa4sv3dq']) // liveness signal intact
217
+ expect(ledger.seenRecently('bxa4sv3dq', T0 + 1)).toBe(true) // dedup recorded
218
+ expect(alive).toEqual([])
219
+ })
220
+
221
+ it('a foreground-shell task-notification with NO handback in flight changes nothing else', () => {
222
+ // A background Bash shell death has no subagent-watcher onFinish — the
223
+ // ledger entry simply ages out; nothing consults it for shells.
224
+ const ledger = new CliTaskNotificationLedger()
225
+ ledger.record('bxa4sv3dq', 'completed', T0)
226
+ expect(ledger.seenRecently('bxa4sv3dq', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1)).toBe(false)
227
+ })
228
+ })
229
+
230
+ // ── Wiring pins — the gateway must actually consult the ledger ───────────────
231
+ // gateway.ts is not unit-instantiable; pin the two wiring sites statically
232
+ // (repo precedent: subagent-handback-marker.test.ts scans gateway source).
233
+ describe('gateway wiring pins', () => {
234
+ const here = dirname(fileURLToPath(import.meta.url))
235
+ const gatewaySrc = readFileSync(join(here, '..', 'gateway', 'gateway.ts'), 'utf8')
236
+
237
+ it('onSessionEvent records terminal task-notifications into the ledger', () => {
238
+ expect(gatewaySrc).toMatch(
239
+ /if \(ev\.kind === 'task_notification'\) cliTaskNotifLedger\.record\(ev\.taskId, ev\.status, Date\.now\(\)\)/,
240
+ )
241
+ })
242
+
243
+ it('the onFinish decide callsite passes the fail-open seenRecently read', () => {
244
+ expect(gatewaySrc).toMatch(
245
+ /cliTaskNotificationSeen: cliTaskNotifLedger\.seenRecently\(agentId, Date\.now\(\)\)/,
246
+ )
247
+ })
248
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Regression test for fleet-audit B2 — the bridge's background reconnect
3
+ * loop must never surface a failed connect attempt as a process-level
4
+ * unhandledRejection.
5
+ *
6
+ * Live evidence (kdogg, 2026-07-17):
7
+ * bridge-crash.log: `unhandledRejection … Error: Failed to connect
8
+ * | at doConnect (dist/server.js:24188) …`
9
+ *
10
+ * Mechanism: `scheduleReconnect()`'s timer invoked `doConnect()` without a
11
+ * rejection handler. `doConnect` returns a promise that REJECTS whenever
12
+ * the `Bun.connect` attempt fails (gateway restarting out from under a
13
+ * long-lived bridge — the exact window every planned `docker restart`
14
+ * opens). The initial-connect call sites attach a `.catch`; the retry
15
+ * timer did not, so every failed background retry escaped as an
16
+ * unhandledRejection. Pre-#3033 that killed the bridge process outright
17
+ * (Claude Code never respawns a dead MCP server → mute agent); post-#3033
18
+ * it still spams `bridge-crash.log` with pseudo-crash breadcrumbs and
19
+ * leans on a global process handler for survival.
20
+ *
21
+ * Outcome asserted: with nothing listening on the socket path, the client
22
+ * runs through its initial attempt AND several background retries without
23
+ * a single unhandledRejection reaching the process. RED without the
24
+ * `.catch` in scheduleReconnect's timer, GREEN with it.
25
+ *
26
+ * Run with: bun test telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts
27
+ */
28
+ import { describe, it, expect } from "bun:test";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { createIpcClient } from "../bridge/ipc-client.js";
32
+
33
+ describe("ipc-client background reconnect", () => {
34
+ it("a failed reconnect attempt never escapes as a process-level unhandledRejection", async () => {
35
+ const captured: unknown[] = [];
36
+ const onUnhandled = (err: unknown) => {
37
+ captured.push(err);
38
+ };
39
+ process.on("unhandledRejection", onUnhandled);
40
+
41
+ // Nothing listens here — every connect attempt fails, exercising both
42
+ // the (already-handled) initial attempt and the retry-timer path.
43
+ const socketPath = join(tmpdir(), `ipc-b2-${crypto.randomUUID()}.sock`);
44
+
45
+ const handle = await createIpcClient({
46
+ socketPath,
47
+ agentName: "b2-test",
48
+ onInbound: () => {},
49
+ onPermission: () => {},
50
+ onStatus: () => {},
51
+ log: () => {},
52
+ reconnectDelayMs: 15,
53
+ maxReconnectDelayMs: 30,
54
+ });
55
+
56
+ try {
57
+ // Long enough for several background retries (15ms, 30ms, 30ms, …)
58
+ // plus the macrotask on which unhandledRejection is delivered.
59
+ await new Promise((resolve) => setTimeout(resolve, 300));
60
+ } finally {
61
+ handle.close();
62
+ // Give any in-flight rejection its delivery tick before detaching.
63
+ await new Promise((resolve) => setTimeout(resolve, 50));
64
+ process.off("unhandledRejection", onUnhandled);
65
+ }
66
+
67
+ expect(handle.isConnected()).toBe(false);
68
+ expect(captured).toEqual([]);
69
+ });
70
+ });
@@ -282,6 +282,92 @@ describe('narrative-lane golden — clearActivitySummary finalize', () => {
282
282
  })
283
283
  })
284
284
 
285
+ // ── #4348 — silent-sentinel activity card is SUPPRESSED (deleted, not finalized) ─
286
+ // A turn whose entire user-facing outcome is a bare NO_REPLY / HEARTBEAT_OK
287
+ // (the sentinel-reply-guard dropped the sentinel-only reply, or the flush net
288
+ // classified the captured text as silent) must leave NO visible telemetry card
289
+ // in the chat — most visibly on the forced-synthesis handback turns a
290
+ // background sub-agent injects. A real reply's card must still finalize.
291
+ describe('narrative-lane golden — silent-sentinel card suppression (#4348)', () => {
292
+ // Open the feed card on a fresh (working) turn FIRST — the card-open gate
293
+ // (mayOpenActivityCard) won't open one once the answer is already delivered —
294
+ // then stamp the turn's terminal outcome, exactly as the real turn does:
295
+ // the card opens mid-work, and lastReplyText / finalAnswerEverDelivered are
296
+ // only known at turn end.
297
+ async function openThenClear(over: Partial<CurrentTurn>) {
298
+ const { lane, calls } = makeLane()
299
+ const turn = makeLaneTurn(lane)
300
+ lane.showNarrativeStep(turn, 'Doing the work now')
301
+ await turn.activityInFlight
302
+ const cardId = turn.activityMessageId
303
+ expect(cardId).not.toBeNull()
304
+ Object.assign(turn, over)
305
+ lane.clearActivitySummary(turn)
306
+ await settle()
307
+ return { calls, cardId, turn }
308
+ }
309
+
310
+ it('reply("NO_REPLY") turn: DELETES the card, never edits a done record', async () => {
311
+ // The guard drops the sentinel-only reply before chat, but the blocked
312
+ // tool_use still stamps lastReplyText — the exact `✓ NO_REPLY` noise card.
313
+ const { calls, cardId, turn } = await openThenClear({
314
+ replyCalled: true,
315
+ lastReplyText: 'NO_REPLY',
316
+ finalAnswerEverDelivered: false,
317
+ })
318
+ expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
319
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
320
+ expect(turn.activityMessageId).toBeNull()
321
+ })
322
+
323
+ it('HEARTBEAT_OK. (trailing punctuation, case-insensitive) is also suppressed', async () => {
324
+ const { calls, cardId } = await openThenClear({
325
+ replyCalled: true,
326
+ lastReplyText: 'heartbeat_ok.',
327
+ finalAnswerEverDelivered: false,
328
+ })
329
+ expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
330
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
331
+ })
332
+
333
+ it('flush path (no reply): prose + trailing NO_REPLY (H6/#2053) is suppressed', async () => {
334
+ const { calls, cardId } = await openThenClear({
335
+ replyCalled: false,
336
+ lastReplyText: '',
337
+ capturedText: ["Nothing actionable in today's digest.", 'NO_REPLY'],
338
+ finalAnswerEverDelivered: false,
339
+ })
340
+ expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
341
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
342
+ })
343
+
344
+ it('normal reply turn: still FINALIZES the card (edit, no delete)', async () => {
345
+ // The guarantee the fix must not break: a real answer keeps its record.
346
+ const { calls, cardId, turn } = await openThenClear({
347
+ replyCalled: true,
348
+ lastReplyText: 'The fix is deployed and the tests are green.',
349
+ finalAnswerEverDelivered: true,
350
+ })
351
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
352
+ .toBeGreaterThanOrEqual(1)
353
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
354
+ expect(turn.activityMessageId).toBeNull()
355
+ })
356
+
357
+ it('reply "prose\\nNO_REPLY" that WAS delivered (finalAnswerEverDelivered) keeps its card', async () => {
358
+ // The guard does NOT drop a reply with non-marker content, so its prose
359
+ // reached chat — endsWithSilentMarker must NOT suppress a delivered reply.
360
+ const { calls, cardId } = await openThenClear({
361
+ replyCalled: true,
362
+ lastReplyText: 'Here is the full answer to your question.\nNO_REPLY',
363
+ finalAnswerEverDelivered: true,
364
+ })
365
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
366
+ .toBeGreaterThanOrEqual(1)
367
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
368
+ })
369
+ })
370
+
285
371
  // ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
286
372
  // The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
287
373
  // into its deps, records the delivered answer into ONE OutboundDedupCache;
@@ -260,3 +260,69 @@ describe('Part B — handback-while-busy: exactly one card, never a frozen "Queu
260
260
  expect(rec.edits).toHaveLength(0)
261
261
  })
262
262
  })
263
+
264
+ describe('Part B — synthetic (fabricated) message ids never 400 the queued card', () => {
265
+ /** Recording bot that enforces the REAL Telegram Bot API contract on
266
+ * `reply_parameters.message_id`: anything non-integer or beyond signed int32
267
+ * is hard-rejected with the exact 400 the live gateway hit
268
+ * (gateway-supervisor.log 2026-08-04, msg=1785846295635) — BEFORE recording,
269
+ * exactly like the wire call. `allow_sending_without_reply` does not bypass
270
+ * the range check, only the message-not-found case. */
271
+ function withTelegramStrictBot(h: Harness) {
272
+ const sends: SendRec[] = []
273
+ let nextId = 9001
274
+ ;(h.deps as unknown as { bot: unknown }).bot = {
275
+ api: {
276
+ sendRichMessage: async (chatId: string, msg: { markdown: string }, opts: Record<string, unknown>) => {
277
+ const rp = opts.reply_parameters as { message_id?: unknown } | undefined
278
+ if (rp != null) {
279
+ const mid = rp.message_id
280
+ if (typeof mid !== 'number' || !Number.isInteger(mid) || mid <= 0 || mid >= 2 ** 31) {
281
+ throw new Error(
282
+ `Call to 'sendRichMessage' failed! (400: Bad Request: field "message_id" must be a valid Number)`,
283
+ )
284
+ }
285
+ }
286
+ const id = nextId++
287
+ sends.push({ chatId, markdown: msg.markdown, opts, id })
288
+ return { message_id: id }
289
+ },
290
+ editMessageText: async () => true,
291
+ deleteMessage: async () => true,
292
+ },
293
+ }
294
+ return { sends }
295
+ }
296
+
297
+ it('sends the queued card UNANCHORED (no 400) when the parked message id is a fabricated Date.now() timestamp', async () => {
298
+ const h = makeHarness()
299
+ const rec = withTelegramStrictBot(h)
300
+
301
+ // Turn A mints on an idle session.
302
+ handleSessionEvent(h.deps, enqueue('501'))
303
+ expect(rec.sends).toHaveLength(0)
304
+
305
+ // A synthetic enqueue (subagent handback / boot resume) parks mid-turn with
306
+ // a fabricated ms-timestamp message id — finite, but NOT a Telegram id.
307
+ handleSessionEvent(h.deps, enqueue('1785846295635', 'handback: worker done'))
308
+ await settle()
309
+ expect(__parkedTurnStartCountForTest()).toBe(1)
310
+
311
+ // The card SENT (no 400 — RED on the pre-fix guard, which forwarded the
312
+ // 13-digit id into reply_parameters and lost the whole card)…
313
+ expect(rec.sends).toHaveLength(1)
314
+ // …and it sent WITHOUT reply-linkage: no reply_parameters at all.
315
+ expect(rec.sends[0]!.opts.reply_parameters).toBeUndefined()
316
+ })
317
+
318
+ it('still reply-anchors when the parked message id is a real Telegram id', async () => {
319
+ const h = makeHarness()
320
+ const rec = withTelegramStrictBot(h)
321
+
322
+ handleSessionEvent(h.deps, enqueue('501'))
323
+ handleSessionEvent(h.deps, enqueue('502', 'real mid-turn message'))
324
+ await settle()
325
+ expect(rec.sends).toHaveLength(1)
326
+ expect((rec.sends[0]!.opts.reply_parameters as { message_id: number }).message_id).toBe(502)
327
+ })
328
+ })