switchroom 0.18.17 → 0.18.19

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1544 -619
  12. package/telegram-plugin/dist/server.js +32 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/format.ts +137 -213
  15. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  16. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  17. package/telegram-plugin/gateway/gateway.ts +399 -85
  18. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  19. package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
  20. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  21. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  22. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  24. package/telegram-plugin/llm-error-present.ts +474 -0
  25. package/telegram-plugin/operator-events.ts +7 -1
  26. package/telegram-plugin/permission-title.ts +172 -10
  27. package/telegram-plugin/premium-recovery.ts +101 -0
  28. package/telegram-plugin/raw-error-scrub.ts +73 -0
  29. package/telegram-plugin/retry-api-call.ts +8 -2
  30. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  31. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  32. package/telegram-plugin/send-gate-observability.ts +65 -20
  33. package/telegram-plugin/send-gate.test.ts +143 -1
  34. package/telegram-plugin/send-gate.ts +212 -19
  35. package/telegram-plugin/session-tail.ts +16 -0
  36. package/telegram-plugin/shared/local-time.ts +69 -0
  37. package/telegram-plugin/stream-reply-handler.ts +5 -14
  38. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  39. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  40. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  41. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  42. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  43. package/telegram-plugin/tests/format-consistency.test.ts +68 -53
  44. package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
  45. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  46. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  47. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  48. package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
  49. package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
  50. package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
  51. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  52. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  53. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  54. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  55. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  56. package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
  57. package/telegram-plugin/tests/telegram-format.test.ts +86 -31
  58. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  59. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  60. package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
  61. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  62. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  63. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  64. package/telegram-plugin/tier-downgrade.ts +198 -0
  65. package/telegram-plugin/tool-activity-summary.ts +99 -0
  66. package/telegram-plugin/turn-flush-safety.ts +4 -3
  67. package/telegram-plugin/worker-activity-feed.ts +509 -409
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Unit tests for the MODEL-TIER downgrade failover (second recovery tier).
3
+ *
4
+ * The pure `decideTierDowngrade` owns the decision the gateway consults inside
5
+ * the `all-blocked` branch of doFireFleetAutoFallback — i.e. AFTER account-swap
6
+ * has been tried and found no account still serving the walled premium model.
7
+ * `planTierDowngrade` then routes that decision + the resume-gate verdict into
8
+ * what the gateway should DO (downgrade / suppress the give-up / fall through),
9
+ * and `renderTierDowngradeNotice` owns the exact user-facing wording. All three
10
+ * are pure so the decision, the concurrent-turn suppression (LOW-9a), and the
11
+ * honesty of the broadcast (no "revert to the premium model" promise) are pinned
12
+ * without a process restart.
13
+ *
14
+ * Contract (post loop-guard reconciliation, option b — the ceremonial
15
+ * `.tier-downgrade-attempts` counter was REMOVED):
16
+ * - overload + the session is NOT on a premium model (override null, or the
17
+ * override resolves to the configured default) → NO downgrade ('skip'); the
18
+ * account-swap path already handled it or there is no lower tier to fall to.
19
+ * This is also the natural loop bound: after the downgrade boot the override
20
+ * is gone, so a re-entry lands on 'on-default' and never re-downgrades.
21
+ * - overload + a premium model walled across ALL accounts → 'downgrade' to the
22
+ * configured default.
23
+ * - a resume restart already armed in this process (gate 'skip-inflight') →
24
+ * 'suppress': no give-up card, no re-downgrade (the armed restart resumes).
25
+ * - the downgrade broadcast states resume-on-default + manual re-issue and
26
+ * carries NO promise of an automatic revert to the premium model.
27
+ */
28
+
29
+ import { describe, it, expect } from 'vitest'
30
+ import {
31
+ decideTierDowngrade,
32
+ planTierDowngrade,
33
+ renderTierDowngradeNotice,
34
+ } from '../tier-downgrade.js'
35
+
36
+ // The gateway passes resolveMainModel; here a near-identity resolver (maps the
37
+ // unset/`default` sentinel to the switchroom default, exactly like the real one)
38
+ // is enough to exercise the canonicalization guard.
39
+ const resolve = (t: string): string =>
40
+ t === '' || t === 'default' ? 'claude-sonnet-5' : t
41
+
42
+ describe('decideTierDowngrade — precedence + downgrade target', () => {
43
+ it('NO downgrade when the session is on the configured default (override null)', () => {
44
+ // Overload + account-swap all-blocked, but nothing premium is active — the
45
+ // account-swap path owns this and there is no lower tier. => skip.
46
+ const d = decideTierDowngrade({ sessionOverride: null, configuredDefault: 'opus', resolve })
47
+ expect(d).toEqual({ action: 'skip', reason: 'on-default' })
48
+ })
49
+
50
+ it('NO downgrade when the override resolves to the configured default (alias vs id)', () => {
51
+ // A premium-looking override that is actually the default in another spelling
52
+ // must never read as a premium tier (that would loop). Same resolver both
53
+ // sides → on-default.
54
+ const d = decideTierDowngrade({
55
+ sessionOverride: 'default',
56
+ configuredDefault: 'claude-sonnet-5',
57
+ resolve,
58
+ })
59
+ expect(d).toEqual({ action: 'skip', reason: 'on-default' })
60
+ })
61
+
62
+ it('NO downgrade (and no blind fall) when the configured default is unreadable', () => {
63
+ const d = decideTierDowngrade({ sessionOverride: 'fable', configuredDefault: null, resolve })
64
+ expect(d).toEqual({ action: 'skip', reason: 'unresolved' })
65
+ })
66
+
67
+ it('DOWNGRADES a walled premium model to the configured default', () => {
68
+ const d = decideTierDowngrade({ sessionOverride: 'fable', configuredDefault: 'opus', resolve })
69
+ expect(d).toEqual({ action: 'downgrade', toModel: 'opus', fromModel: 'fable' })
70
+ })
71
+
72
+ it('downgrade target is GENERAL — any premium model → the agent default (not hardcoded fable→opus)', () => {
73
+ const d = decideTierDowngrade({
74
+ sessionOverride: 'sr-x-ai/grok-4',
75
+ configuredDefault: 'claude-sonnet-5',
76
+ resolve,
77
+ })
78
+ expect(d).toEqual({ action: 'downgrade', toModel: 'claude-sonnet-5', fromModel: 'sr-x-ai/grok-4' })
79
+ })
80
+ })
81
+
82
+ describe('renderTierDowngradeNotice — HONEST wording (no revert-to-premium promise)', () => {
83
+ const notice = renderTierDowngradeNotice('fable', 'opus', 'klanker')
84
+
85
+ it('states the turn resumes on the DEFAULT to keep working', () => {
86
+ expect(notice).toContain('opus')
87
+ expect(notice.toLowerCase()).toContain('resuming on the default')
88
+ })
89
+
90
+ it('tells the user to RE-ISSUE the premium /model themselves', () => {
91
+ expect(notice).toContain('/model fable')
92
+ expect(notice.toLowerCase()).toContain("won't switch back on its own")
93
+ })
94
+
95
+ it("carries NO promise of an automatic revert to the premium/fable model", () => {
96
+ // The HIGH finding: the prior wording lied ("It reverts to `fable` on the
97
+ // next restart"). Assert none of those false promises can creep back.
98
+ const lower = notice.toLowerCase()
99
+ expect(lower).not.toMatch(/reverts? to `?fable/)
100
+ expect(lower).not.toContain('reverts to `fable`')
101
+ expect(lower).not.toContain('on the next restart')
102
+ expect(lower).not.toMatch(/revert(s|ing)? to the premium/)
103
+ expect(lower).not.toMatch(/back to `?fable`? (on|at|after)/)
104
+ })
105
+ })
106
+
107
+ describe('planTierDowngrade — routing (downgrade / suppress / skip)', () => {
108
+ const skipDecision = { action: 'skip', reason: 'on-default' } as const
109
+ const downgradeDecision = { action: 'downgrade', toModel: 'opus', fromModel: 'fable' } as const
110
+
111
+ it("a 'skip' decision routes to 'skip' regardless of the gate verdict", () => {
112
+ expect(planTierDowngrade(skipDecision, 'resume', 'ag').kind).toBe('skip')
113
+ expect(planTierDowngrade(skipDecision, 'skip-inflight', 'ag').kind).toBe('skip')
114
+ expect(planTierDowngrade(skipDecision, 'skip-stale', 'ag').kind).toBe('skip')
115
+ })
116
+
117
+ it("downgrade + gate 'resume' → 'downgrade' with the honest notice", () => {
118
+ const plan = planTierDowngrade(downgradeDecision, 'resume', 'klanker')
119
+ expect(plan.kind).toBe('downgrade')
120
+ if (plan.kind === 'downgrade') {
121
+ expect(plan.toModel).toBe('opus')
122
+ expect(plan.fromModel).toBe('fable')
123
+ // The plan carries EXACTLY the honest renderer output — no revert promise.
124
+ expect(plan.notice).toBe(renderTierDowngradeNotice('fable', 'opus', 'klanker'))
125
+ expect(plan.notice.toLowerCase()).not.toContain('on the next restart')
126
+ }
127
+ })
128
+
129
+ it("LOW-9a: downgrade + gate 'skip-inflight' → 'suppress' (a resume restart is already armed, no give-up card)", () => {
130
+ // Two turns hit all-blocked in the same pre-restart process. Turn 1 armed a
131
+ // downgrade (or account-swap) restart. Turn 2 must NOT broadcast a "could
132
+ // not be recovered" give-up — the armed restart resumes the interrupted turn.
133
+ const plan = planTierDowngrade(downgradeDecision, 'skip-inflight', 'klanker')
134
+ expect(plan.kind).toBe('suppress')
135
+ })
136
+
137
+ it("downgrade + gate 'skip-stale' → 'skip' (turn too old to resume; fall through to the all-blocked card)", () => {
138
+ const plan = planTierDowngrade(downgradeDecision, 'skip-stale', 'klanker')
139
+ expect(plan.kind).toBe('skip')
140
+ })
141
+ })
@@ -31,9 +31,7 @@ import {
31
31
  normalizeParagraphBreaks,
32
32
  normalizePunctuation,
33
33
  stripExcessBold,
34
- addParagraphSpacers,
35
34
  splitMarkdownChunks,
36
- PARAGRAPH_SPACER,
37
35
  RICH_MESSAGE_MAX_CHARS,
38
36
  } from '../format.js'
39
37
 
@@ -130,7 +128,9 @@ describe('decideTurnFlush — prose+trailing-sentinel is suppressed, not leaked
130
128
  // the real gateway turn-flush render pipeline (post-#2669 rich-markdown path):
131
129
  // decideTurnFlush -> join('\n\n')
132
130
  // -> repairEscapedWhitespace -> normalizeParagraphBreaks
133
- // -> addParagraphSpacers -> splitMarkdownChunks -> sendRichMessage
131
+ // -> splitMarkdownChunks -> sendRichMessage
132
+ // (no paragraph-spacer pass — the NBSP spacer was removed in the #2669
133
+ // follow-up; gaps are plain `\n\n`, one visible blank line)
134
134
  // so it pins the end-to-end fix, not just the pure decision. The corpus is a
135
135
  // REAL captured-transcript shape (three separate content[i].text blocks, one
136
136
  // stored UNTRIMMED with a trailing '\n' exactly as session-tail.ts pushes
@@ -156,8 +156,7 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
156
156
  const d = decideTurnFlush({ chatId: '12345', replyCalled: false, capturedText: blocks })
157
157
  expect(d.kind).toBe('flush')
158
158
  const joined = (d as { kind: 'flush'; text: string }).text
159
- const normalized = normalizeParagraphBreaks(repairEscapedWhitespace(joined))
160
- return addParagraphSpacers(normalized)
159
+ return normalizeParagraphBreaks(repairEscapedWhitespace(joined))
161
160
  }
162
161
 
163
162
  it('separates whole blocks with a visible paragraph gap, not a wall-of-text', () => {
@@ -167,32 +166,29 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
167
166
  expect(out).toContain('The `auth` handler looks correct')
168
167
  expect(out).toContain('Want me to open a PR')
169
168
  // The wall-of-text failure mode glues two blocks one '\n' apart. Assert the
170
- // boundary carries a real paragraph break with the injected visible spacer
171
- // line (#2692 rich-path spacer), and NOT a single-newline join.
172
- expect(out).toContain(`\n\n${PARAGRAPH_SPACER}\n\n`)
169
+ // boundary carries a real paragraph break (plain `\n\n`, one blank line —
170
+ // no NBSP spacer), and NOT a single-newline join.
171
+ expect(out).toContain('flagged.\n\nThe `auth`')
173
172
  expect(out).not.toContain('flagged.\nThe `auth`')
174
- // A spacer sits specifically between block 1 and block 2.
175
- const b1 = out.indexOf('flagged.')
176
- const b2 = out.indexOf('The `auth` handler')
177
- expect(out.slice(b1, b2)).toContain(PARAGRAPH_SPACER)
173
+ // No U+00A0 anywhere.
174
+ expect(out).not.toContain(String.fromCharCode(0xa0))
178
175
  })
179
176
 
180
177
  it('collapses the untrimmed-trailing-newline stack — no 3+ newline run reaches the wire', () => {
181
178
  const out = renderLikeTurnFlush(realBlocks)
182
179
  // Block 2's trailing '\n' + the '\n\n' join = 3 newlines; normalize
183
- // collapses 3+ runs to '\n\n' and addParagraphSpacers wedges exactly one
184
- // spacer, so no doubled/stacked blank run survives.
180
+ // collapses 3+ runs to '\n\n', so no doubled/stacked blank run survives.
185
181
  expect(out).not.toMatch(/\n{3,}/)
186
- // One spacer per block transition: 3 blocks → 2 gaps → 2 spacers.
187
- const spacerCount = out.split(PARAGRAPH_SPACER).length - 1
188
- expect(spacerCount).toBe(2)
182
+ // One blank-line gap per block transition: 3 blocks → 2 gaps.
183
+ const gapCount = (out.match(/\n\n/g) ?? []).length
184
+ expect(gapCount).toBe(2)
189
185
  })
190
186
 
191
187
  it('the whole separated answer stays in one rich chunk here (well under 32768)', () => {
192
188
  const out = renderLikeTurnFlush(realBlocks)
193
189
  const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
194
190
  expect(chunks.length).toBe(1)
195
- expect(chunks[0]).toContain(PARAGRAPH_SPACER)
191
+ expect(chunks[0]).toContain('\n\n')
196
192
  })
197
193
 
198
194
  it('still SUPPRESSES a real transcript that deliberately terminates with a bare NO_REPLY (#2053 guard intact)', () => {
@@ -228,9 +224,9 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
228
224
  // runs (gateway executeReply):
229
225
  // repairEscapedWhitespace -> normalizeParagraphBreaks -> redactOutboundText
230
226
  // -> stripExcessBold(normalizePunctuation) -> scrubVoice
231
- // -> addParagraphSpacers (send side)
227
+ // (no send-side paragraph-spacer pass — removed in the #2669 follow-up).
232
228
  // The original #2798 change gave turn-flush the paragraph steps + redact +
233
- // scrub + spacers but OMITTED `stripExcessBold(normalizePunctuation(...))`.
229
+ // scrub but OMITTED `stripExcessBold(normalizePunctuation(...))`.
234
230
  // This suite reconstructs the deterministic format chain of BOTH paths (the
235
231
  // runtime-only redact + voice-scrub steps are literally the same calls on both
236
232
  // paths and are out of scope here) and pins that turn-flush now matches reply
@@ -312,7 +308,7 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
312
308
  function formatChain(text: string): string {
313
309
  let t = normalizeParagraphBreaks(repairEscapedWhitespace(text))
314
310
  t = stripExcessBold(normalizePunctuation(t))
315
- return addParagraphSpacers(t)
311
+ return t
316
312
  }
317
313
 
318
314
  const input =
@@ -11,7 +11,7 @@
11
11
  import { describe, it, expect } from 'bun:test'
12
12
  import { GrammyError } from 'grammy'
13
13
  import { classifyRejection } from '../gateway/unhandled-rejection-policy.js'
14
- import { FLOOD_WAIT_ACTIVE } from '../retry-api-call.js'
14
+ import { FLOOD_WAIT_ACTIVE, LOCAL_RESOURCE_EXHAUSTED } from '../retry-api-call.js'
15
15
 
16
16
  // ── Real GrammyError fixtures ──────────────────────────────────────────────
17
17
 
@@ -241,3 +241,29 @@ describe('classifyRejection — FLOOD_WAIT_ACTIVE marker (#3084)', () => {
241
241
  expect(classifyRejection(new Error('FLOOD_WAIT_ACTIVE-ish but not it'))).toBe('shutdown')
242
242
  })
243
243
  })
244
+
245
+ describe('classifyRejection — LOCAL_RESOURCE_EXHAUSTED marker (#3099)', () => {
246
+ // retry-api-call throws this plain Error marker (retry-api-call.ts:344) when a
247
+ // send fails on LOCAL disk/memory exhaustion (ENOSPC/EDQUOT/EIO/ENOMEM) rather
248
+ // than retrying it (#2923). Like its sibling FLOOD_WAIT_ACTIVE it is a plain
249
+ // Error, not a GrammyError, so without an explicit entry it fell into the
250
+ // `!isGrammy → shutdown` branch — crashing the gateway when the box is ALREADY
251
+ // out of disk, which drives a fresh round of boot-time sends/staging writes at
252
+ // an exhausted resource (the amplification the #2923 marker exists to avoid).
253
+ it('returns "log_only" for a leaked LOCAL_RESOURCE_EXHAUSTED marker', () => {
254
+ // Mirror the real throw shape from retry-api-call.ts:344 —
255
+ // `Object.assign(new Error(LOCAL_RESOURCE_EXHAUSTED), { original: err })`.
256
+ const err = Object.assign(new Error(LOCAL_RESOURCE_EXHAUSTED), {
257
+ original: Object.assign(new Error('ENOSPC: no space left on device'), {
258
+ code: 'ENOSPC',
259
+ }),
260
+ })
261
+ expect(classifyRejection(err)).toBe('log_only')
262
+ })
263
+
264
+ it('still returns "shutdown" for an unrelated plain Error', () => {
265
+ expect(
266
+ classifyRejection(new Error('LOCAL_RESOURCE_EXHAUSTED-ish but not it')),
267
+ ).toBe('shutdown')
268
+ })
269
+ })
@@ -533,17 +533,20 @@ describe('createWorkerActivityFeed — log sink', () => {
533
533
  const edit = logs.find((l) => l.startsWith('worker-feed: edit'))
534
534
  const finish = logs.find((l) => l.startsWith('worker-feed: finish'))
535
535
 
536
+ // The feed message is per-(chat,thread) now (workers coalesce), so the
537
+ // paint/edit lines are feed-scoped; the terminal `finish` line still names
538
+ // the finishing worker + its state.
536
539
  expect(paint).toBeDefined()
537
- expect(paint).toContain('agent=w-research')
538
540
  expect(paint).toContain('chat=chat-9')
539
541
  expect(paint).toContain('thread=7')
540
542
  expect(paint).toMatch(/msgId=\d+/)
541
543
  expect(paint).toMatch(/bytes=\d+/)
542
544
 
543
545
  expect(edit).toBeDefined()
544
- expect(edit).toContain('agent=w-research')
546
+ expect(edit).toContain('chat=chat-9')
545
547
 
546
548
  expect(finish).toBeDefined()
549
+ expect(finish).toContain('agent=w-research')
547
550
  expect(finish).toContain('state=done')
548
551
  })
549
552