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,216 @@
1
+ /**
2
+ * Tests for the generic-transient PROBE-ONLY 429 path (#failover-429-corroborate).
3
+ *
4
+ * This pins the OUTCOMES the gateway promises for a bare `rate_limit_error`
5
+ * (generic-transient) 429 after Ken picked Option A (probe-only):
6
+ *
7
+ * - generic-transient + HEALTHY probe → the probe's ONLY effect is the
8
+ * silent broker quota refresh. Account-inert at the runner: NO throttle
9
+ * notice, NO self-restart nudge, NO `throttled_until` soft-defer, NO second
10
+ * card. The calm rate-limited card the gateway already emitted stays the
11
+ * ONLY user-visible output.
12
+ * - generic-transient + WALL (probe corroborates) → the escalation path runs
13
+ * exactly like the account-scoped `fire`: the corroborated-wall announcement
14
+ * posts and the dead turn is resumed. No redundant calm card is emitted from
15
+ * this branch — the announcement IS the output.
16
+ * - litellm-local → the runner does NOT fire at all (account-inert by
17
+ * mechanism, not discipline): the request never reached Anthropic, so
18
+ * account state must not be touched. `account-scoped` likewise takes its own
19
+ * `fire` path, not `fireProbeOnly`. Pinned via the pure classification gate
20
+ * the gateway consults before calling `fireProbeOnly`.
21
+ *
22
+ * The runner side is exercised with fully injected deps (no gateway import),
23
+ * mirroring throttle-tier-wiring.test.ts.
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest'
27
+ import {
28
+ createThrottleTierRunner,
29
+ type ThrottleBrokerClient,
30
+ type ThrottleTierRunnerDeps,
31
+ } from '../gateway/throttle-tier-wiring.js'
32
+ import { classification429WarrantsCorroboration } from '../throttle-tier.js'
33
+
34
+ const NOW = Date.UTC(2026, 6, 12, 8, 0, 0)
35
+
36
+ interface Harness {
37
+ deps: ThrottleTierRunnerDeps
38
+ calls: {
39
+ markThrottled: Array<{ until: number; probeOnly?: boolean }>
40
+ claims: string[]
41
+ notices: Array<{ chatId: string | number; markdown: string }>
42
+ deferrals: string[]
43
+ restarts: string[]
44
+ logs: string[]
45
+ timers: Array<{ ms: number; fn: () => void; cancelled: boolean }>
46
+ }
47
+ }
48
+
49
+ function makeHarness(opts: {
50
+ markThrottledResult?:
51
+ | { account: string; throttled_until: number; escalated: boolean; rolledTo?: string | null }
52
+ | 'unreachable'
53
+ | 'throw'
54
+ turnInFlight?: () => boolean
55
+ newestTurnStartedAt?: () => number | null
56
+ } = {}): Harness {
57
+ const nowMs = NOW
58
+ const calls: Harness['calls'] = {
59
+ markThrottled: [],
60
+ claims: [],
61
+ notices: [],
62
+ deferrals: [],
63
+ restarts: [],
64
+ logs: [],
65
+ timers: [],
66
+ }
67
+ const client: ThrottleBrokerClient = {
68
+ async markThrottled(until: number, probeOnly?: boolean) {
69
+ calls.markThrottled.push({ until, probeOnly })
70
+ if (opts.markThrottledResult === 'throw') throw new Error('boom')
71
+ const r = opts.markThrottledResult
72
+ if (r && r !== 'unreachable') return r
73
+ return { account: 'alice', throttled_until: until, escalated: false, rolledTo: null }
74
+ },
75
+ async claimNotification(key: string) {
76
+ calls.claims.push(key)
77
+ return { granted: true }
78
+ },
79
+ }
80
+ const deps: ThrottleTierRunnerDeps = {
81
+ agentName: 'carrie',
82
+ getBrokerClient: async () =>
83
+ opts.markThrottledResult === 'unreachable' ? null : client,
84
+ listNoticeChats: () => ['111', '222'],
85
+ sendNotice: (chatId, markdown) => calls.notices.push({ chatId, markdown }),
86
+ resumeDecide: () => 'resume',
87
+ newestActiveTurnStartedAtMs: opts.newestTurnStartedAt ?? (() => null),
88
+ turnInFlight: opts.turnInFlight ?? (() => false),
89
+ deferRestartToTurnComplete: (_agent, reason) => calls.deferrals.push(reason),
90
+ restartNow: (_agent, reason) => calls.restarts.push(reason),
91
+ log: (m) => calls.logs.push(m),
92
+ now: () => nowMs,
93
+ schedule: (fn, ms) => {
94
+ const t = { ms, fn, cancelled: false }
95
+ calls.timers.push(t)
96
+ return { cancel: () => { t.cancelled = true } }
97
+ },
98
+ jitterMs: () => 0,
99
+ }
100
+ return { deps, calls }
101
+ }
102
+
103
+ describe('generic-transient probe-only — classification gate (litellm-local never fires)', () => {
104
+ it('fires the corroboration probe ONLY for generic-transient', () => {
105
+ expect(classification429WarrantsCorroboration('generic-transient')).toBe(true)
106
+ // litellm-local: request never reached Anthropic — account-inert by mechanism.
107
+ expect(classification429WarrantsCorroboration('litellm-local')).toBe(false)
108
+ // account-scoped: runs its own throttle tier / failover (fire, not fireProbeOnly).
109
+ expect(classification429WarrantsCorroboration('account-scoped')).toBe(false)
110
+ // null: not a rate-limited event.
111
+ expect(classification429WarrantsCorroboration(null)).toBe(false)
112
+ })
113
+ })
114
+
115
+ describe('generic-transient probe-only — HEALTHY probe is account-inert', () => {
116
+ it('probes the broker but emits NOTHING else (calm card stays the only output)', async () => {
117
+ const h = makeHarness() // default result: escalated:false (healthy)
118
+ const runner = createThrottleTierRunner(h.deps)
119
+ await runner.fireProbeOnly('carrie')
120
+
121
+ // The probe reached the broker in probe-only mode…
122
+ expect(h.calls.markThrottled).toHaveLength(1)
123
+ expect(h.calls.markThrottled[0].probeOnly).toBe(true)
124
+
125
+ // …but produced NO user-visible or account-mutating side effects:
126
+ expect(h.calls.notices).toHaveLength(0) // no throttle notice, no second card
127
+ expect(h.calls.claims).toHaveLength(0) // no fleet-dedup broadcast at all
128
+ expect(h.calls.restarts).toHaveLength(0) // no self-restart nudge
129
+ expect(h.calls.deferrals).toHaveLength(0)
130
+ expect(h.calls.timers).toHaveLength(0) // no throttled_until soft-defer / retry nudge
131
+ expect(runner.inspect().nudgePending).toBe(false)
132
+ })
133
+
134
+ it('a broker-unreachable probe is inert and never throws', async () => {
135
+ const h = makeHarness({ markThrottledResult: 'unreachable' })
136
+ const runner = createThrottleTierRunner(h.deps)
137
+ await expect(runner.fireProbeOnly('carrie')).resolves.toBeUndefined()
138
+ expect(h.calls.markThrottled).toHaveLength(0)
139
+ expect(h.calls.notices).toHaveLength(0)
140
+ expect(h.calls.restarts).toHaveLength(0)
141
+ expect(h.calls.timers).toHaveLength(0)
142
+ })
143
+
144
+ it('a markThrottled throw is swallowed and stays inert (no card, no restart)', async () => {
145
+ const h = makeHarness({ markThrottledResult: 'throw' })
146
+ const runner = createThrottleTierRunner(h.deps)
147
+ await expect(runner.fireProbeOnly('carrie')).resolves.toBeUndefined()
148
+ expect(h.calls.notices).toHaveLength(0)
149
+ expect(h.calls.restarts).toHaveLength(0)
150
+ expect(h.calls.timers).toHaveLength(0)
151
+ })
152
+ })
153
+
154
+ describe('generic-transient probe-only — WALL corroborated → escalation', () => {
155
+ it('posts the corroborated-wall announcement and resumes immediately (no calm card, no nudge timer)', async () => {
156
+ const h = makeHarness({
157
+ markThrottledResult: {
158
+ account: 'alice',
159
+ throttled_until: NOW + 60_000,
160
+ escalated: true,
161
+ rolledTo: 'bob',
162
+ },
163
+ })
164
+ const runner = createThrottleTierRunner(h.deps)
165
+ await runner.fireProbeOnly('carrie')
166
+
167
+ // Probe ran in probe-only mode, then escalation took over.
168
+ expect(h.calls.markThrottled[0].probeOnly).toBe(true)
169
+
170
+ // The escalation announcement (fleet-deduped), NOT the staying-put notice.
171
+ expect(h.calls.claims).toEqual([
172
+ `throttle-escalation:alice:111`,
173
+ `throttle-escalation:alice:222`,
174
+ ])
175
+ expect(h.calls.notices).toHaveLength(2)
176
+ expect(h.calls.notices[0].markdown).toContain('actually a wall')
177
+ expect(h.calls.notices[0].markdown).toContain('bob')
178
+
179
+ // Immediate resume via the escalation lever; NO delayed retry nudge armed.
180
+ expect(h.calls.restarts).toEqual(['throttle-escalation-resume'])
181
+ expect(h.calls.timers).toHaveLength(0)
182
+ })
183
+
184
+ it('escalated with rolledTo=null (all blocked) announces but does NOT restart', async () => {
185
+ const h = makeHarness({
186
+ markThrottledResult: {
187
+ account: 'alice',
188
+ throttled_until: NOW + 60_000,
189
+ escalated: true,
190
+ rolledTo: null,
191
+ },
192
+ })
193
+ const runner = createThrottleTierRunner(h.deps)
194
+ await runner.fireProbeOnly('carrie')
195
+ expect(h.calls.notices[0].markdown).toContain('all blocked')
196
+ expect(h.calls.restarts).toHaveLength(0)
197
+ expect(h.calls.timers).toHaveLength(0)
198
+ })
199
+
200
+ it('escalated resume respects the live-turn guards (defers under the dead turn gate)', async () => {
201
+ const h = makeHarness({
202
+ markThrottledResult: {
203
+ account: 'alice',
204
+ throttled_until: NOW + 60_000,
205
+ escalated: true,
206
+ rolledTo: 'bob',
207
+ },
208
+ turnInFlight: () => true,
209
+ newestTurnStartedAt: () => NOW - 60_000, // the dead turn still holds the gate
210
+ })
211
+ const runner = createThrottleTierRunner(h.deps)
212
+ await runner.fireProbeOnly('carrie')
213
+ expect(h.calls.restarts).toHaveLength(0)
214
+ expect(h.calls.deferrals).toEqual(['throttle-escalation-resume'])
215
+ })
216
+ })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * L1 SEAM-BOUNDARY GUARD for the 429 classify→route wiring (follow-up to
3
+ * switchroom#4381 / #4379).
4
+ *
5
+ * `throttle-tier-route-429.test.ts` proves the ROUTING LOGIC of the pure seam
6
+ * `routeRateLimit429` in isolation (generic-transient → fireProbeOnly;
7
+ * litellm-local / account-scoped / null → no probe). But nothing asserted that
8
+ * the GATEWAY still calls that seam at its 429-handling path. That is the exact
9
+ * gap the extraction opened: a future edit deleting the gateway callsite
10
+ * (gateway.ts, `emitGatewayOperatorEvent` rate-limited calm branch) would
11
+ * silently drop the corroboration probe for every `generic-transient` 429 —
12
+ * a real 5h/7d wall hiding behind transient wording would again die with no
13
+ * failover — while the seam's own unit suite stayed fully green.
14
+ *
15
+ * gateway.ts is a side-effecting IIFE that cannot be imported in a unit test
16
+ * (same constraint documented in turn-flush-suppression-wiring.test.ts /
17
+ * activity-card-wiring.test.ts): `emitGatewayOperatorEvent` is a module-scoped,
18
+ * un-exported function reachable only after the gateway boots. So an
19
+ * outcome-level "drive emitGatewayOperatorEvent and observe fireProbeOnly" test
20
+ * is infeasible without exporting internal gateway state — scope creep the
21
+ * repo deliberately avoids. Following the established wiring-guard pattern,
22
+ * this is a STRUCTURAL assertion that pins the load-bearing call site. It
23
+ * COMPLEMENTS the seam suite: the routing outcomes are proven there; this
24
+ * guards that the gateway actually routes through them.
25
+ *
26
+ * The outcome that flips on the regression: with the callsite present, a
27
+ * `generic-transient` 429 reaches `routeRateLimit429(..., throttleTierRunner,
28
+ * agent)` and fires the probe on the REAL runner; delete or neuter the
29
+ * callsite and these assertions go red. Verified fails-red by deleting
30
+ * gateway.ts:7831 locally (see PR body).
31
+ */
32
+ import { describe, it, expect } from 'vitest'
33
+ import { readFileSync } from 'node:fs'
34
+ import { resolve } from 'node:path'
35
+
36
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
37
+
38
+ function between(src: string, startMarker: string, endMarker: string): string {
39
+ const after = src.split(startMarker)[1] ?? ''
40
+ return after.split(endMarker)[0] ?? ''
41
+ }
42
+
43
+ /** Strip comment lines so prose (a docstring mentioning the call, or a comment
44
+ * describing the OLD shape) can neither satisfy nor trip an assertion about
45
+ * the actual CODE. */
46
+ function codeOnly(src: string): string {
47
+ return src
48
+ .split('\n')
49
+ .filter((l) => !l.trim().startsWith('//'))
50
+ .filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
51
+ .join('\n')
52
+ }
53
+
54
+ describe('429 route-seam wiring — the gateway calls routeRateLimit429 (switchroom#4381 L1)', () => {
55
+ // The calm-429 branch: from the classification binding through the surface
56
+ // decision that immediately follows the seam call.
57
+ const branch = between(
58
+ gatewaySrc,
59
+ 'const rateLimit429Classification =',
60
+ "if (surface === 'litellm-local-notice')",
61
+ )
62
+
63
+ it('the calm-429 branch routes the classification through routeRateLimit429', () => {
64
+ expect(branch.length).toBeGreaterThan(100)
65
+ const code = codeOnly(branch)
66
+ // The load-bearing call site. Deleting it drops the corroboration probe for
67
+ // every generic-transient 429 with the seam's unit suite still green — the
68
+ // exact regression this guard exists to catch.
69
+ expect(code).toMatch(/routeRateLimit429\(/)
70
+ // It must be fed the CLASSIFICATION computed for this event, not a literal
71
+ // or a stale variable — otherwise the wrong family would (not) be probed.
72
+ expect(code).toMatch(
73
+ /routeRateLimit429\(\s*rateLimit429Classification\s*,/,
74
+ )
75
+ })
76
+
77
+ it('the seam is handed the REAL throttleTierRunner (a live probe, not a no-op)', () => {
78
+ const code = codeOnly(branch)
79
+ // Passing anything other than the gateway's real runner (e.g. a stub) would
80
+ // make the probe fire into the void — the branch outcome would look correct
81
+ // in a naive grep but corroborate nothing.
82
+ expect(code).toMatch(
83
+ /routeRateLimit429\(\s*rateLimit429Classification\s*,\s*throttleTierRunner\s*,\s*agent\s*\)/,
84
+ )
85
+ })
86
+
87
+ it('routeRateLimit429 is imported into the gateway (the seam is actually linked)', () => {
88
+ // A dangling call to an unimported symbol would fail tsc, but pinning the
89
+ // import makes the dependency edge explicit and its removal a red test too.
90
+ expect(codeOnly(gatewaySrc)).toMatch(/\brouteRateLimit429\b/)
91
+ })
92
+ })
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tests for the classify→route WIRING of the gateway's 429 corroboration path
3
+ * (#failover-429-corroborate, follow-up switchroom#4379).
4
+ *
5
+ * The pure gate `classification429WarrantsCorroboration` is already unit-tested
6
+ * (throttle-tier-probe-only.test.ts), and the runner's `fireProbeOnly` outcomes
7
+ * are pinned there too. What was NOT covered is the WIRING that connects them:
8
+ * the gateway callsite (gateway.ts, `handleOperatorEvent` rate-limited branch)
9
+ * that decides whether to invoke `throttleTierRunner.fireProbeOnly(agent)` for a
10
+ * given classification.
11
+ *
12
+ * gateway.ts is hard to unit-test in isolation and is under a hard line ratchet,
13
+ * so rather than stand up a gateway harness the decision was extracted into a
14
+ * pure, injectable seam — `routeRateLimit429(classification, runner, agent)` in
15
+ * throttle-tier.ts — which the gateway callsite now calls verbatim. These tests
16
+ * exercise that seam with a fake runner that records which entrypoint fired,
17
+ * pinning the three routing outcomes the gateway promises:
18
+ *
19
+ * - generic-transient → routes to fireProbeOnly (the probe fires)
20
+ * - litellm-local → does NOT route to fireProbeOnly (account-inert by
21
+ * mechanism: the request never reached Anthropic)
22
+ * - account-scoped → does NOT route to fireProbeOnly (that classification
23
+ * takes its own `fire` path, not the probe)
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest'
27
+ import { routeRateLimit429, type RateLimit429ProbeRunner } from '../throttle-tier.js'
28
+
29
+ interface FakeRunner extends RateLimit429ProbeRunner {
30
+ probeCalls: string[]
31
+ }
32
+
33
+ function makeFakeRunner(): FakeRunner {
34
+ const probeCalls: string[] = []
35
+ return {
36
+ probeCalls,
37
+ async fireProbeOnly(triggerAgent: string) {
38
+ probeCalls.push(triggerAgent)
39
+ },
40
+ }
41
+ }
42
+
43
+ describe('routeRateLimit429 — gateway classify→route wiring (switchroom#4379)', () => {
44
+ it('generic-transient → routes to fireProbeOnly', () => {
45
+ const runner = makeFakeRunner()
46
+ const fired = routeRateLimit429('generic-transient', runner, 'carrie')
47
+ expect(fired).toBe(true)
48
+ expect(runner.probeCalls).toEqual(['carrie'])
49
+ })
50
+
51
+ it('litellm-local → does NOT route to fireProbeOnly (stays account-inert)', () => {
52
+ const runner = makeFakeRunner()
53
+ const fired = routeRateLimit429('litellm-local', runner, 'carrie')
54
+ expect(fired).toBe(false)
55
+ expect(runner.probeCalls).toHaveLength(0)
56
+ })
57
+
58
+ it('account-scoped → does NOT route to fireProbeOnly (uses fire, not the probe)', () => {
59
+ const runner = makeFakeRunner()
60
+ const fired = routeRateLimit429('account-scoped', runner, 'carrie')
61
+ expect(fired).toBe(false)
62
+ expect(runner.probeCalls).toHaveLength(0)
63
+ })
64
+
65
+ it('null (not a rate-limited event) → does NOT route to fireProbeOnly', () => {
66
+ const runner = makeFakeRunner()
67
+ const fired = routeRateLimit429(null, runner, 'carrie')
68
+ expect(fired).toBe(false)
69
+ expect(runner.probeCalls).toHaveLength(0)
70
+ })
71
+ })
@@ -20,6 +20,7 @@ import {
20
20
  isSilentFlushMarker,
21
21
  isCompositeSilentNoise,
22
22
  endsWithSilentMarker,
23
+ isSilentSentinelCardOutcome,
23
24
  isTurnFlushSafetyEnabled,
24
25
  selectFlushDeliveryText,
25
26
  FLUSH_SUBSTANTIVE_MIN_CHARS,
@@ -893,3 +894,85 @@ describe('selectFlushDeliveryText — structural provenance (followedByToolUse)
893
894
  expect(out).not.toContain('Here are the figures')
894
895
  })
895
896
  })
897
+
898
+ // #4348 — the pure gate that suppresses the per-turn activity/telemetry card
899
+ // when the turn's whole user-facing outcome was an intentional silent sentinel.
900
+ describe('isSilentSentinelCardOutcome (#4348)', () => {
901
+ const base = { replyCalled: false, lastReplyText: '', capturedText: [] as string[], finalAnswerEverDelivered: false }
902
+
903
+ it('reply("NO_REPLY") — the blocked sentinel-only reply is a silent outcome', () => {
904
+ expect(isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: 'NO_REPLY' })).toBe(true)
905
+ })
906
+
907
+ it('trailing punctuation and case variants still count as silent', () => {
908
+ for (const t of ['NO_REPLY.', 'no_reply', 'HEARTBEAT_OK', 'heartbeat_ok!', ' NO_REPLY ']) {
909
+ expect(isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: t })).toBe(true)
910
+ }
911
+ })
912
+
913
+ it('composite silent noise ("Sent.\\nNO_REPLY\\nNO_REPLY") via the reply payload is silent', () => {
914
+ expect(
915
+ isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: 'Sent.\nNO_REPLY\nNO_REPLY' }),
916
+ ).toBe(true)
917
+ })
918
+
919
+ it('flush path (no reply): prose + trailing NO_REPLY (H6/#2053) is silent', () => {
920
+ expect(
921
+ isSilentSentinelCardOutcome({
922
+ ...base,
923
+ replyCalled: false,
924
+ capturedText: ["Nothing actionable in today's digest.", 'NO_REPLY'],
925
+ }),
926
+ ).toBe(true)
927
+ })
928
+
929
+ it('a real reply is NOT silent — the card is a legitimate record', () => {
930
+ expect(
931
+ isSilentSentinelCardOutcome({
932
+ ...base,
933
+ replyCalled: true,
934
+ lastReplyText: 'The three services are all green.',
935
+ finalAnswerEverDelivered: true,
936
+ }),
937
+ ).toBe(false)
938
+ })
939
+
940
+ it('finalAnswerEverDelivered short-circuits even when a stray NO_REPLY is the last reply text', () => {
941
+ // An interim answer landed, then a trailing sentinel — the delivered answer wins.
942
+ expect(
943
+ isSilentSentinelCardOutcome({
944
+ ...base,
945
+ replyCalled: true,
946
+ lastReplyText: 'NO_REPLY',
947
+ finalAnswerEverDelivered: true,
948
+ }),
949
+ ).toBe(false)
950
+ })
951
+
952
+ it('a delivered reply that merely mentions the sentinel in prose is NOT silent', () => {
953
+ // Non-marker content ⇒ the sentinel-reply-guard would not drop it ⇒ delivered.
954
+ expect(
955
+ isSilentSentinelCardOutcome({
956
+ ...base,
957
+ replyCalled: true,
958
+ lastReplyText: 'Reply with exactly NO_REPLY if there is nothing to add.',
959
+ }),
960
+ ).toBe(false)
961
+ })
962
+
963
+ it('endsWithSilentMarker does NOT suppress a DELIVERED reply of "prose\\nNO_REPLY"', () => {
964
+ // The guard lets prose+trailing-NO_REPLY through the reply tool, so its
965
+ // prose reached chat; the card must stay even though it ends with a marker.
966
+ expect(
967
+ isSilentSentinelCardOutcome({
968
+ ...base,
969
+ replyCalled: true,
970
+ lastReplyText: 'Here is the full answer.\nNO_REPLY',
971
+ }),
972
+ ).toBe(false)
973
+ })
974
+
975
+ it('a genuinely empty dark turn (no reply, no text) is NOT a sentinel', () => {
976
+ expect(isSilentSentinelCardOutcome({ ...base })).toBe(false)
977
+ })
978
+ })
@@ -120,6 +120,65 @@ export function classify429Detail(text: string): RateLimit429Classification {
120
120
  return 'generic-transient'
121
121
  }
122
122
 
123
+ /**
124
+ * #failover-429-corroborate — does this terminal 429 classification warrant a
125
+ * fire-and-forget broker corroboration probe (throttle-tier runner's
126
+ * PROBE-ONLY entrypoint) IN ADDITION to the calm rate-limited card?
127
+ *
128
+ * TRUE for `generic-transient` ONLY. That family (a bare `rate_limit_error`
129
+ * body / novel wording that matches neither the account-scoped negation
130
+ * strings nor litellm-local) used to drop on the calm-card floor with NO broker
131
+ * contact — so a genuine 5h/7d wall hiding behind transient wording was never
132
+ * probed, and the turn died with no failover. Routing it through the runner's
133
+ * `fireProbeOnly` lets the broker take ONE live quota probe (rate-bounded) and
134
+ * convert a real wall into failover. A HEALTHY probe is fully account-inert:
135
+ * probe-only records NO `throttled_until`, sends NO throttle notice, arms NO
136
+ * self-restart nudge, and adds NO second card — so the calm rate-limited card
137
+ * stays the ONLY user-visible output, exactly as before this path existed.
138
+ *
139
+ * FALSE (never corroborate) for:
140
+ * - `litellm-local` — INVARIANT: the request never reached Anthropic (the
141
+ * proxy's own tpm/rpm limiter tripped), so account state must not be
142
+ * touched. This mechanism, not discipline, keeps that path account-inert.
143
+ * - `account-scoped` — already runs its own throttle tier / failover path.
144
+ * - `null` — not a rate-limited event.
145
+ */
146
+ export function classification429WarrantsCorroboration(
147
+ classification: RateLimit429Classification | null,
148
+ ): boolean {
149
+ return classification === 'generic-transient'
150
+ }
151
+
152
+ /**
153
+ * Minimal runner shape the classify→route seam needs: just the PROBE-ONLY
154
+ * entrypoint. The full gateway `ThrottleTierRunner` satisfies it structurally,
155
+ * and a test can supply a fake that records the call.
156
+ */
157
+ export interface RateLimit429ProbeRunner {
158
+ fireProbeOnly(triggerAgent: string): Promise<void>
159
+ }
160
+
161
+ /**
162
+ * #failover-429-corroborate — the classify→route WIRING extracted from the
163
+ * gateway callsite so the routing decision (not just the gate) is unit-testable
164
+ * without a gateway harness. Fires the runner's PROBE-ONLY entrypoint IFF the
165
+ * classification warrants corroboration (`generic-transient` only, per
166
+ * `classification429WarrantsCorroboration`); `litellm-local`, `account-scoped`,
167
+ * and `null` never fire it. Fire-and-forget: the runner promise is intentionally
168
+ * not awaited (the gateway must not block the operator-event path on a probe).
169
+ * Returns `true` when a probe was fired, `false` otherwise — observable in tests
170
+ * and unused at the gateway callsite.
171
+ */
172
+ export function routeRateLimit429(
173
+ classification: RateLimit429Classification | null,
174
+ runner: RateLimit429ProbeRunner,
175
+ agent: string,
176
+ ): boolean {
177
+ if (!classification429WarrantsCorroboration(classification)) return false
178
+ void runner.fireProbeOnly(agent)
179
+ return true
180
+ }
181
+
123
182
  /**
124
183
  * Build the `rate_limit_429_classified` runtime metric for one terminal
125
184
  * rate-limited operator event — the instrumentation that lets an operator
@@ -135,6 +135,85 @@ export function endsWithSilentMarker(text: string | undefined): boolean {
135
135
  return isSilentFlushMarker(lines[lines.length - 1])
136
136
  }
137
137
 
138
+ /**
139
+ * Inputs for {@link isSilentSentinelCardOutcome} — the fields of the ending
140
+ * turn the card-suppression gate reads. All are already tracked on the
141
+ * gateway's `CurrentTurn`; passed as a plain struct so the decision is a pure,
142
+ * unit-testable core (`gateway.ts` / `narrative-lane.ts` are not importable in
143
+ * tests).
144
+ */
145
+ export interface SilentSentinelCardInput {
146
+ /** True when the model called `reply` / `stream_reply` at least once. */
147
+ replyCalled: boolean
148
+ /**
149
+ * The most-recent `reply` / `stream_reply` `input.text` this turn — empty
150
+ * string when the reply tool was never called (`CurrentTurn.lastReplyText`).
151
+ */
152
+ lastReplyText: string
153
+ /**
154
+ * Raw assistant text blocks accumulated across the turn — the same source
155
+ * `decideTurnFlush` classifies (`CurrentTurn.capturedText`). Only consulted
156
+ * on the reply-never-called (flush) path.
157
+ */
158
+ capturedText: string[]
159
+ /**
160
+ * A SUBSTANTIVE final answer reached the user at some point this turn
161
+ * (`CurrentTurn.finalAnswerEverDelivered`). When true the card is a
162
+ * legitimate record beside a delivered answer and is NEVER suppressed.
163
+ */
164
+ finalAnswerEverDelivered: boolean
165
+ }
166
+
167
+ /**
168
+ * Decide whether an ending turn's user-facing outcome was an INTENTIONAL silent
169
+ * sentinel (NO_REPLY / HEARTBEAT_OK) — the deterministic gate that suppresses
170
+ * the per-turn activity/telemetry card for a turn that said nothing to the user.
171
+ *
172
+ * The symptom (#4348): a background sub-agent handback injects a forced-synthesis
173
+ * turn, the parent legitimately answers `NO_REPLY`, and the gateway still
174
+ * finalizes a `🤖 Agent · done · 0 tools · … · ✓ NO_REPLY` card in the chat.
175
+ * `sentinel-reply-guard-pretool.mjs` already drops the sentinel-only reply so
176
+ * nothing reaches chat, but the blocked `tool_use` still stamps `lastReplyText`,
177
+ * and the activity card finalizes as pure noise.
178
+ *
179
+ * This reuses the SAME silent-turn predicates the flush safety net and the Stop
180
+ * hook use — it invents no second notion of "silent":
181
+ * - Reply path (`replyCalled`): the model called `reply` with a sentinel-ONLY
182
+ * payload, which `sentinel-reply-guard-pretool.mjs` drops before chat, so the
183
+ * card's whole outcome is a silence signal that never became a message. Only
184
+ * `isSilentFlushMarker` / `isCompositeSilentNoise` count here — a
185
+ * `prose\nNO_REPLY` reply is NOT dropped by that guard (it has non-marker
186
+ * content), so its prose WAS delivered and its card must stay. `endsWithSilentMarker`
187
+ * is deliberately NOT applied to a delivered reply.
188
+ * - Flush path (reply never called): the turn's captured terminal text is its
189
+ * outcome, so match every shape `decideTurnFlush` treats as `silent-marker` —
190
+ * bare marker, composite noise, and prose+trailing-`NO_REPLY` (#2053 / H6).
191
+ *
192
+ * The `finalAnswerEverDelivered` short-circuit is the normal-case guarantee: a
193
+ * turn that actually delivered a substantive answer keeps its card, so a real
194
+ * reply (even one a later stray `NO_REPLY` follows) is never suppressed.
195
+ */
196
+ export function isSilentSentinelCardOutcome(input: SilentSentinelCardInput): boolean {
197
+ // A substantive answer reached the user — the card is a legitimate record.
198
+ if (input.finalAnswerEverDelivered) return false
199
+ // Reply-tool outcome: a sentinel-ONLY payload the guard dropped before chat.
200
+ if (isSilentFlushMarker(input.lastReplyText) || isCompositeSilentNoise(input.lastReplyText)) {
201
+ return true
202
+ }
203
+ // Flush outcome: reply never called; classify the captured terminal text with
204
+ // the exact predicates decideTurnFlush's `silent-marker` skip uses.
205
+ if (!input.replyCalled) {
206
+ const joined = input.capturedText.join('\n\n').trim()
207
+ if (
208
+ joined.length > 0 &&
209
+ (isSilentFlushMarker(joined) || isCompositeSilentNoise(joined) || endsWithSilentMarker(joined))
210
+ ) {
211
+ return true
212
+ }
213
+ }
214
+ return false
215
+ }
216
+
138
217
  /**
139
218
  * Substantive-answer floor (chars, trimmed). Mirrors
140
219
  * `final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS` and