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,165 @@
1
+ /**
2
+ * Unit tests for the "premium model recovered" ping.
3
+ *
4
+ * - `decidePremiumRecovery` is the PURE recovery predicate: fires iff a marker
5
+ * is pending AND the broker shows the premium tier servable again (at least
6
+ * one account neither `exhausted` nor `premium_walled`) — the exact
7
+ * complement of the `all-blocked` condition that fired the downgrade. This
8
+ * IS the deterministic recovery signal: both fields are the broker's live-
9
+ * authoritative verdicts, so the ping never depends on model judgement.
10
+ * - `renderPremiumRecoveryPing` pins the honest, session-scoped wording +
11
+ * button label.
12
+ * - `premiumRecoveryClaimKey` keys the fleet-wide dedup on agent + token.
13
+ * - the `.premium-recovery` carrier round-trips + is shape-gated (parity with
14
+ * the `.session-model` / `.session-effort` carriers).
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
18
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import {
22
+ decidePremiumRecovery,
23
+ renderPremiumRecoveryPing,
24
+ premiumRecoveryClaimKey,
25
+ } from '../premium-recovery.js'
26
+ import {
27
+ writePremiumRecoveryFile,
28
+ readPremiumRecoveryFile,
29
+ clearPremiumRecoveryFile,
30
+ parsePremiumRecovery,
31
+ PREMIUM_RECOVERY_FILE,
32
+ } from '../gateway/session-model-file.js'
33
+
34
+ describe('decidePremiumRecovery — deterministic recovery predicate', () => {
35
+ it('no marker → never fires', () => {
36
+ expect(
37
+ decidePremiumRecovery({ hasMarker: false, accounts: [{ exhausted: false, premiumWalled: false }] }),
38
+ ).toEqual({ fire: false, reason: 'no-marker' })
39
+ })
40
+
41
+ it('marker pending but EVERY account still walled/exhausted → no fire (still-walled)', () => {
42
+ const d = decidePremiumRecovery({
43
+ hasMarker: true,
44
+ accounts: [
45
+ { exhausted: true, premiumWalled: false },
46
+ { exhausted: false, premiumWalled: true },
47
+ { exhausted: true, premiumWalled: true },
48
+ ],
49
+ })
50
+ expect(d).toEqual({ fire: false, reason: 'still-walled' })
51
+ })
52
+
53
+ it('marker pending AND one account can serve the premium tier again → fire', () => {
54
+ const d = decidePremiumRecovery({
55
+ hasMarker: true,
56
+ accounts: [
57
+ { exhausted: true, premiumWalled: true },
58
+ { exhausted: false, premiumWalled: false }, // recovered
59
+ ],
60
+ })
61
+ expect(d).toEqual({ fire: true, reason: 'recovered' })
62
+ })
63
+
64
+ it('an account that is exhausted-but-not-premium-walled does NOT count as recovered', () => {
65
+ // The premium tier is only servable on an account that is BOTH not
66
+ // exhausted AND not premium-walled.
67
+ const d = decidePremiumRecovery({
68
+ hasMarker: true,
69
+ accounts: [{ exhausted: true, premiumWalled: false }],
70
+ })
71
+ expect(d.fire).toBe(false)
72
+ })
73
+
74
+ it('an account that is premium-walled-but-not-exhausted does NOT count as recovered', () => {
75
+ const d = decidePremiumRecovery({
76
+ hasMarker: true,
77
+ accounts: [{ exhausted: false, premiumWalled: true }],
78
+ })
79
+ expect(d.fire).toBe(false)
80
+ })
81
+
82
+ it('empty account list with a pending marker → no fire (nothing servable)', () => {
83
+ expect(decidePremiumRecovery({ hasMarker: true, accounts: [] }).fire).toBe(false)
84
+ })
85
+ })
86
+
87
+ describe('renderPremiumRecoveryPing — honest wording', () => {
88
+ it('names the model, says available again, and offers a session-scoped switch', () => {
89
+ const p = renderPremiumRecoveryPing('fable')
90
+ expect(p.text).toContain('`fable`')
91
+ expect(p.text.toLowerCase()).toContain('available again')
92
+ expect(p.text.toLowerCase()).toContain('this session')
93
+ expect(p.buttonText).toBe('Switch to fable')
94
+ // Must NOT over-promise a durable pin.
95
+ expect(p.text.toLowerCase()).not.toContain('permanently')
96
+ expect(p.text.toLowerCase()).not.toContain('forever')
97
+ })
98
+ })
99
+
100
+ describe('premiumRecoveryClaimKey', () => {
101
+ it('keys the dedup on agent + token so different drops never collide', () => {
102
+ expect(premiumRecoveryClaimKey('klanker', 'fable')).toBe('premium-recovery:klanker:fable')
103
+ expect(premiumRecoveryClaimKey('klanker', 'fable')).not.toBe(
104
+ premiumRecoveryClaimKey('klanker', 'opus'),
105
+ )
106
+ })
107
+ })
108
+
109
+ describe('.premium-recovery carrier — round-trip + shape gate', () => {
110
+ let dir: string
111
+ beforeEach(() => {
112
+ dir = mkdtempSync(join(tmpdir(), 'premium-recovery-'))
113
+ })
114
+ afterEach(() => {
115
+ rmSync(dir, { recursive: true, force: true })
116
+ })
117
+
118
+ it('write → read round-trips model + chats (a VALID marker is NOT swept)', () => {
119
+ writePremiumRecoveryFile(dir, 'fable', ['12345', '67890'])
120
+ const rec = readPremiumRecoveryFile(dir)
121
+ expect(rec?.premiumModel).toBe('fable')
122
+ expect(rec?.chats).toEqual(['12345', '67890'])
123
+ expect(typeof rec?.ts).toBe('number')
124
+ // A well-formed marker must survive a read — only corrupt files are swept.
125
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
126
+ })
127
+
128
+ it('clear removes the marker', () => {
129
+ writePremiumRecoveryFile(dir, 'fable', ['12345'])
130
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
131
+ clearPremiumRecoveryFile(dir)
132
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(false)
133
+ expect(readPremiumRecoveryFile(dir)).toBeNull()
134
+ })
135
+
136
+ it('refuses a non-canonical model token (shell-injection shape)', () => {
137
+ expect(() => writePremiumRecoveryFile(dir, 'bad name; rm -rf /', ['12345'])).toThrow()
138
+ })
139
+
140
+ it('refuses an empty chat list (a marker with nowhere to ping is a bug)', () => {
141
+ expect(() => writePremiumRecoveryFile(dir, 'fable', [])).toThrow()
142
+ })
143
+
144
+ it('parse rejects corrupt JSON / bad shape / bad token / empty chats', () => {
145
+ expect(parsePremiumRecovery('{broken')).toBeNull()
146
+ expect(parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: [], ts: 1 }))).toBeNull()
147
+ expect(
148
+ parsePremiumRecovery(JSON.stringify({ premiumModel: 'bad;name', chats: ['1'], ts: 1 })),
149
+ ).toBeNull()
150
+ expect(
151
+ parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: [1, 2], ts: 1 })),
152
+ ).toBeNull()
153
+ expect(parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: ['1'] }))).toBeNull()
154
+ })
155
+
156
+ it('read returns null on a corrupt on-disk marker AND sweeps the garbled file', () => {
157
+ writeFileSync(join(dir, PREMIUM_RECOVERY_FILE), '{not json\n')
158
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
159
+ expect(readPremiumRecoveryFile(dir)).toBeNull()
160
+ // LOW-1: a corrupt marker is garbage-collected on read so it can't linger
161
+ // forever (neither the ping path nor the manual-switch clear could ever
162
+ // delete it — both read null first).
163
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(false)
164
+ })
165
+ })
@@ -130,7 +130,12 @@ describe('#3155 reactions route through the send gate (cosmetic)', () => {
130
130
  // The reaction actually reached the API (was admitted, not shed)...
131
131
  expect(setMessageReaction).toHaveBeenCalled()
132
132
  // ...and its 429 was recorded by the breaker — the whole gap this closes.
133
- expect(onFloodWait).toHaveBeenCalledWith(7)
133
+ // #3111: the retry policy now also passes the call's opts so the gateway can
134
+ // open a scope-precise window (a reaction 429 implies only that chat).
135
+ expect(onFloodWait).toHaveBeenCalledWith(
136
+ 7,
137
+ expect.objectContaining({ chat_id: 'chatA', priorityClass: 'cosmetic' }),
138
+ )
134
139
  })
135
140
  })
136
141
 
@@ -576,6 +576,27 @@ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)'
576
576
  expect(out).toBe('ok')
577
577
  expect(seen).toEqual([42])
578
578
  })
579
+
580
+ it('passes the call opts to onFloodWait so the gateway can open a scope-precise window (#3111)', async () => {
581
+ const seen: { sec: number; chat_id?: string; chatType?: string }[] = []
582
+ const sleep = vi.fn(async () => {})
583
+ let n = 0
584
+ const retry = createRetryApiCall({
585
+ maxRetries: 3,
586
+ sleep,
587
+ onFloodWait: (sec, opts) => seen.push({ sec, chat_id: opts?.chat_id, chatType: opts?.chatType }),
588
+ })
589
+ const out = await retry(
590
+ async () => {
591
+ if (n++ === 0) throw errors.floodWait(7)
592
+ return 'ok'
593
+ },
594
+ { chat_id: '42', chatType: 'supergroup' },
595
+ )
596
+ expect(out).toBe('ok')
597
+ // The hook received the same chat scope the send carried — not a bare number.
598
+ expect(seen).toEqual([{ sec: 7, chat_id: '42', chatType: 'supergroup' }])
599
+ })
579
600
  })
580
601
 
581
602
  describe('#3084 — a long flood ban fails FAST instead of sleeping for hours', () => {
@@ -120,13 +120,12 @@ describe('handleStreamReply', () => {
120
120
  expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
121
121
  })
122
122
 
123
- it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
123
+ it('rich path sends the multi-paragraph `\\n\\n` gap byte-exact (no NBSP spacer)', async () => {
124
+ // The NBSP paragraph-spacer pass was removed in the #2669 follow-up — the
125
+ // rich renderer shows a plain `\n\n` gap as one blank line, so the handler
126
+ // passes the text through unchanged.
124
127
  const state = makeState()
125
- // Spacer dep replaces every `\n\n` gap with a visible marker so we can
126
- // assert the rich path ran it (mirrors the real gateway wiring).
127
- const deps = makeDeps(bot, {
128
- addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
129
- })
128
+ const deps = makeDeps(bot)
130
129
 
131
130
  const pending = handleStreamReply(
132
131
  { chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
@@ -137,14 +136,13 @@ describe('handleStreamReply', () => {
137
136
  await pending
138
137
 
139
138
  expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
140
- expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
139
+ expect(richSendMarkdown(bot)).toBe('Para one.\n\nPara two.')
140
+ expect(richSendMarkdown(bot)).not.toContain(String.fromCharCode(0xa0))
141
141
  })
142
142
 
143
- it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
143
+ it('literal format=text path is byte-exact too', async () => {
144
144
  const state = makeState()
145
- const deps = makeDeps(bot, {
146
- addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
147
- })
145
+ const deps = makeDeps(bot)
148
146
 
149
147
  const pending = handleStreamReply(
150
148
  { chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
@@ -155,7 +153,6 @@ describe('handleStreamReply', () => {
155
153
  await pending
156
154
 
157
155
  expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
158
- // Literal path is byte-exact — no spacer injected.
159
156
  expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
160
157
  })
161
158
 
@@ -19,8 +19,6 @@ import {
19
19
  repairEscapedWhitespace,
20
20
  escapeMarkdown,
21
21
  splitMarkdownChunks,
22
- addParagraphSpacers,
23
- PARAGRAPH_SPACER,
24
22
  RICH_MESSAGE_MAX_CHARS,
25
23
  } from '../format.js'
26
24
 
@@ -187,62 +185,119 @@ describe('splitMarkdownChunks', () => {
187
185
  })
188
186
 
189
187
  // -------------------------------------------------------------------------
190
- // Chunk-boundary spacer hygiene. addParagraphSpacers injects a
191
- // `\n\n${PARAGRAPH_SPACER}\n\n` gap between prose paragraphs. When a cut
192
- // lands inside that gap, the boundary must not leave a chunk that opens or
193
- // ends with a bare U+00A0 spacer line (a stray blank bubble line).
188
+ // Chunk-boundary blank-line hygiene. Paragraph gaps are now plain `\n\n`
189
+ // (the NBSP spacer was removed in the #2669 follow-up). When a cut lands in
190
+ // a gap, the boundary must not leave a chunk that opens or ends with a bare
191
+ // blank line, and no visible content may be dropped.
194
192
  // -------------------------------------------------------------------------
195
193
 
196
- test('no chunk starts or ends with a bare U+00A0 spacer line (reviewer repro)', () => {
197
- // The exact reviewer repro: a spacer gap straddling a small cap.
194
+ test('no chunk starts or ends with a bare blank line at a `\\n\\n` gap cut', () => {
198
195
  const A = 'Alpha sentence one'
199
196
  const B = 'Bravo sentence two'
200
- const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
201
- const chunks = splitMarkdownChunks(spaced, 33)
197
+ const text = `${A}.\n\n${B}.`
198
+ const chunks = splitMarkdownChunks(text, 33)
202
199
  expect(chunks.length).toBeGreaterThan(1)
203
- const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
200
+ const blankOnly = /^[ \t]*$/
204
201
  for (const c of chunks) {
205
202
  const lines = c.split('\n')
206
- expect(spacerOnly.test(lines[0])).toBe(false)
207
- expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
203
+ expect(blankOnly.test(lines[0])).toBe(false)
204
+ expect(blankOnly.test(lines[lines.length - 1])).toBe(false)
208
205
  }
209
206
  })
210
207
 
211
208
  test('visible paragraph content survives the boundary (no text dropped)', () => {
212
209
  const A = 'Alpha sentence one'
213
210
  const B = 'Bravo sentence two'
214
- const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
215
- const chunks = splitMarkdownChunks(spaced, 33)
211
+ const text = `${A}.\n\n${B}.`
212
+ const chunks = splitMarkdownChunks(text, 33)
216
213
  const rejoined = chunks.join('\n')
217
214
  expect(rejoined).toContain(`${A}.`)
218
215
  expect(rejoined).toContain(`${B}.`)
219
216
  })
220
217
 
221
- test('spacer-boundary strip is robust across several gaps and small caps', () => {
218
+ test('blank-line-boundary strip is robust across several gaps and small caps', () => {
222
219
  const paras = Array.from({ length: 6 }, (_, i) => `Paragraph ${i} body text here.`)
223
- const spaced = addParagraphSpacers(paras.join('\n\n'))
224
- const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
220
+ const text = paras.join('\n\n')
221
+ const blankOnly = /^[ \t]*$/
225
222
  for (const cap of [20, 31, 40, 64]) {
226
- const chunks = splitMarkdownChunks(spaced, cap)
223
+ const chunks = splitMarkdownChunks(text, cap)
227
224
  for (const c of chunks) {
228
225
  const lines = c.split('\n')
229
- expect(spacerOnly.test(lines[0])).toBe(false)
230
- expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
226
+ expect(blankOnly.test(lines[0])).toBe(false)
227
+ expect(blankOnly.test(lines[lines.length - 1])).toBe(false)
231
228
  }
232
- // No visible word is dropped: concatenating the chunks' non-blank,
233
- // non-spacer tokens reproduces the original word sequence. (Word-level,
234
- // not line-level, because a small cap may split mid-word — that's the
235
- // chunker's normal space-boundary behaviour, orthogonal to spacers.)
236
- const words = (s: string): string[] =>
237
- s.split(/\s+/).filter((w) => w.length > 0 && w !== PARAGRAPH_SPACER)
238
- // Join chunks with a space — each chunk is a separate Telegram message,
239
- // so inter-chunk whitespace is irrelevant; what matters is no word is
240
- // lost or fused. (A small cap may end a chunk mid-sentence at a space
241
- // boundary, e.g. "...here." | "Paragraph 1...", which is normal.)
229
+ // No visible word is dropped: concatenating the chunks' non-blank tokens
230
+ // reproduces the original word sequence. (Word-level, not line-level,
231
+ // because a small cap may split mid-word — the chunker's normal
232
+ // space-boundary behaviour.)
233
+ const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
242
234
  expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
243
235
  }
244
236
  })
245
237
 
238
+ // -------------------------------------------------------------------------
239
+ // Entity-aware chunking (#finding-3): a cut must never bisect an inline
240
+ // span (`**bold**`, `` `code` ``, `_italic_`, `[label](href)`), which would
241
+ // strand an unclosed delimiter in the emitted chunk.
242
+ // -------------------------------------------------------------------------
243
+
244
+ test('a bold/code/link span straddling the cap is not bisected (balanced delimiters)', () => {
245
+ // Build a body where each inline span sits right around a small cap so the
246
+ // naive space/newline cut would land inside it. Every span is shorter than
247
+ // the smallest cap tried (the longest is the 34-char link), so a straddling
248
+ // span can always be kept whole — the entity-aware back-off must do so.
249
+ const filler = 'x'.repeat(30)
250
+ const body =
251
+ `${filler} **bold span here** ` +
252
+ `${filler} \`code span here\` ` +
253
+ `${filler} [label here](https://ex.com/a-b-c) ` +
254
+ `${filler} _italic span here_ ${filler}`
255
+ for (const cap of [40, 50, 60, 70, 80]) {
256
+ const chunks = splitMarkdownChunks(body, cap)
257
+ for (const c of chunks) {
258
+ // Balanced `**` and single-backtick delimiters in every chunk.
259
+ expect((c.match(/\*\*/g) ?? []).length % 2).toBe(0)
260
+ expect((c.match(/`/g) ?? []).length % 2).toBe(0)
261
+ // No chunk ends mid-link (an open `](` with no closing `)`), and no
262
+ // chunk starts with an orphan link tail.
263
+ const opens = (c.match(/\]\(/g) ?? []).length
264
+ const closesAfterOpen = (c.match(/\]\([^)\n]*\)/g) ?? []).length
265
+ expect(opens).toBe(closesAfterOpen)
266
+ }
267
+ // Nothing is dropped.
268
+ const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
269
+ expect(words(chunks.join(' '))).toEqual(words(body))
270
+ }
271
+ })
272
+
273
+ test('a `***bold-italic***` / `___…___` span straddling the cap keeps SINGLE-marker balance', () => {
274
+ // Regression for the triple-marker case: without the `***…***` pattern, the
275
+ // bold pattern matches only the inner `**…**`, so a cut inside `***x***`
276
+ // strands the lone outer `*` (odd asterisk count → the italic is lost). A
277
+ // `**`-PAIR balance check misses that, so assert SINGLE-`*` / SINGLE-`_`
278
+ // balance in every chunk.
279
+ const filler = 'x'.repeat(30)
280
+ const body =
281
+ `${filler} ***bold italic here*** ` +
282
+ `${filler} ___under bold here___ ${filler}`
283
+ for (const cap of [40, 50, 60, 70]) {
284
+ const chunks = splitMarkdownChunks(body, cap)
285
+ for (const c of chunks) {
286
+ // SINGLE-marker balance: an even count of `*` and of `_` in every chunk
287
+ // (a stranded lone `*`/`_` from a bisected triple span makes it odd).
288
+ expect((c.match(/\*/g) ?? []).length % 2).toBe(0)
289
+ expect((c.match(/_/g) ?? []).length % 2).toBe(0)
290
+ }
291
+ // Each triple span survives intact in exactly one chunk.
292
+ const rejoined = chunks.join('\n')
293
+ expect(rejoined).toContain('***bold italic here***')
294
+ expect(rejoined).toContain('___under bold here___')
295
+ // Nothing dropped.
296
+ const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
297
+ expect(words(chunks.join(' '))).toEqual(words(body))
298
+ }
299
+ })
300
+
246
301
  test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
247
302
  const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
248
303
  const chunks = splitMarkdownChunks(text, 40)
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Integration test for the tier-downgrade GLUE (`runTierDowngrade`,
3
+ * tier-downgrade-wiring.ts) — the gateway orchestration the pure
4
+ * `decideTierDowngrade` / `renderTierDowngradeNotice` tests do NOT cover.
5
+ *
6
+ * This is the test the @overlord review asked for (the MEDIUM "reverts to
7
+ * fable" misinform slipped through precisely because the glue that emits the
8
+ * BROADCAST notice text had no test — only the pure renderer did). It drives
9
+ * the real runner with injected seams and PINS the broadcast notice:
10
+ * - asserts the resume-on-DEFAULT wording + the manual re-issue instruction;
11
+ * - FAILS on any revert-to-premium phrasing (the exact regression).
12
+ * It also covers the resume-gate suppression path and the carrier-before-arm
13
+ * ordering invariant.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest'
17
+ import {
18
+ runTierDowngrade,
19
+ type TierDowngradeRunnerDeps,
20
+ type TierDowngradeOutcome,
21
+ } from '../gateway/tier-downgrade-wiring.js'
22
+
23
+ interface Recorder {
24
+ carrier: Array<{ dir: string; toModel: string; cfg: string }>
25
+ arms: number
26
+ markers: Array<{ dir: string; premiumModel: string }>
27
+ notices: string[]
28
+ restarts: string[]
29
+ logs: string[]
30
+ }
31
+
32
+ function makeDeps(
33
+ over: Partial<TierDowngradeRunnerDeps> & {
34
+ sessionOverride?: string | null
35
+ configuredDefault?: string | null
36
+ gateVerdict?: 'resume' | 'skip-inflight' | 'skip-stale'
37
+ carrierThrows?: boolean
38
+ markerThrows?: boolean
39
+ agentDir?: string | null
40
+ } = {},
41
+ ): { deps: TierDowngradeRunnerDeps; rec: Recorder } {
42
+ const rec: Recorder = { carrier: [], arms: 0, markers: [], notices: [], restarts: [], logs: [] }
43
+ // A near-identity resolver like the real resolveMainModel: the unset/`default`
44
+ // sentinel maps to the switchroom default, everything else is itself.
45
+ const resolve = (t: string): string => (t === '' || t === 'default' ? 'claude-opus-4-8' : t)
46
+ const deps: TierDowngradeRunnerDeps = {
47
+ getAgentDir: () => (over.agentDir === undefined ? '/tmp/agent' : over.agentDir),
48
+ getConfiguredDefault: () => (over.configuredDefault === undefined ? 'opus' : over.configuredDefault),
49
+ getSessionOverride: () => (over.sessionOverride === undefined ? 'fable' : over.sessionOverride),
50
+ resolve,
51
+ peekResumeGate: () => over.gateVerdict ?? 'resume',
52
+ writeCarrier: (dir, toModel, cfg) => {
53
+ if (over.carrierThrows) throw new Error('disk full')
54
+ rec.carrier.push({ dir, toModel, cfg })
55
+ },
56
+ armResumeGate: () => {
57
+ rec.arms += 1
58
+ },
59
+ writeRecoveryMarker: (dir, premiumModel) => {
60
+ if (over.markerThrows) throw new Error('marker write failed')
61
+ rec.markers.push({ dir, premiumModel })
62
+ },
63
+ broadcastNotice: (md) => rec.notices.push(md),
64
+ selfRestart: (agent) => rec.restarts.push(agent),
65
+ selfAgent: (t) => `self-${t}`,
66
+ log: (msg) => rec.logs.push(msg),
67
+ ...over,
68
+ }
69
+ return { deps, rec }
70
+ }
71
+
72
+ describe('runTierDowngrade — broadcast notice honesty (the pinned regression)', () => {
73
+ it('downgrade path broadcasts EXACTLY ONE notice with resume-on-default wording', () => {
74
+ const { deps, rec } = makeDeps({ sessionOverride: 'fable', configuredDefault: 'opus' })
75
+ const outcome: TierDowngradeOutcome = runTierDowngrade('klanker', deps)
76
+ expect(outcome).toBe('downgraded')
77
+ expect(rec.notices).toHaveLength(1)
78
+ const notice = rec.notices[0]
79
+ // Resume-on-DEFAULT + the manual re-issue instruction.
80
+ expect(notice).toContain('`opus`')
81
+ expect(notice).toContain('/model fable')
82
+ expect(notice.toLowerCase()).toContain("won't switch back on its own")
83
+ })
84
+
85
+ it('the notice NEVER promises a revert to the premium model (the MEDIUM regression)', () => {
86
+ const { deps, rec } = makeDeps({ sessionOverride: 'fable', configuredDefault: 'opus' })
87
+ runTierDowngrade('klanker', deps)
88
+ const notice = rec.notices[0].toLowerCase()
89
+ // The exact false phrasing from the pre-5d9bbf44 revision, and its kin.
90
+ expect(notice).not.toContain('reverts to `fable`')
91
+ expect(notice).not.toContain('revert to `fable`')
92
+ expect(notice).not.toMatch(/reverts? to fable/)
93
+ expect(notice).not.toMatch(/switch(es)? back.*on (the )?next restart/)
94
+ expect(notice).not.toMatch(/automatically.*(fable|premium)/)
95
+ })
96
+ })
97
+
98
+ describe('runTierDowngrade — side-effect ordering + outcomes', () => {
99
+ it('downgrade: carrier written BEFORE the arm, marker + notice + restart all fire', () => {
100
+ const { deps, rec } = makeDeps({ sessionOverride: 'fable', configuredDefault: 'opus' })
101
+ const outcome = runTierDowngrade('klanker', deps)
102
+ expect(outcome).toBe('downgraded')
103
+ expect(rec.carrier).toEqual([{ dir: '/tmp/agent', toModel: 'opus', cfg: 'opus' }])
104
+ expect(rec.arms).toBe(1)
105
+ expect(rec.markers).toEqual([{ dir: '/tmp/agent', premiumModel: 'fable' }])
106
+ expect(rec.restarts).toEqual(['self-klanker'])
107
+ })
108
+
109
+ it('carrier write THROWS → abort: NO arm, NO marker, NO notice, NO restart (skip)', () => {
110
+ const { deps, rec } = makeDeps({ carrierThrows: true })
111
+ const outcome = runTierDowngrade('klanker', deps)
112
+ expect(outcome).toBe('skip')
113
+ expect(rec.arms).toBe(0)
114
+ expect(rec.markers).toHaveLength(0)
115
+ expect(rec.notices).toHaveLength(0)
116
+ expect(rec.restarts).toHaveLength(0)
117
+ })
118
+
119
+ it('marker write THROWS → downgrade still completes (marker is best-effort)', () => {
120
+ const { deps, rec } = makeDeps({ markerThrows: true })
121
+ const outcome = runTierDowngrade('klanker', deps)
122
+ expect(outcome).toBe('downgraded')
123
+ expect(rec.arms).toBe(1)
124
+ expect(rec.notices).toHaveLength(1)
125
+ expect(rec.restarts).toEqual(['self-klanker'])
126
+ expect(rec.markers).toHaveLength(0)
127
+ })
128
+
129
+ it('resume-gate skip-inflight → restart-pending, NO notice, NO restart, NO carrier', () => {
130
+ const { deps, rec } = makeDeps({ gateVerdict: 'skip-inflight' })
131
+ const outcome = runTierDowngrade('klanker', deps)
132
+ expect(outcome).toBe('restart-pending')
133
+ expect(rec.carrier).toHaveLength(0)
134
+ expect(rec.arms).toBe(0)
135
+ expect(rec.notices).toHaveLength(0)
136
+ expect(rec.restarts).toHaveLength(0)
137
+ })
138
+
139
+ it('resume-gate skip-stale → skip (give up), NO notice/restart', () => {
140
+ const { deps, rec } = makeDeps({ gateVerdict: 'skip-stale' })
141
+ const outcome = runTierDowngrade('klanker', deps)
142
+ expect(outcome).toBe('skip')
143
+ expect(rec.notices).toHaveLength(0)
144
+ expect(rec.restarts).toHaveLength(0)
145
+ })
146
+
147
+ it('session on the configured default (override null) → skip, no downgrade', () => {
148
+ const { deps, rec } = makeDeps({ sessionOverride: null })
149
+ const outcome = runTierDowngrade('klanker', deps)
150
+ expect(outcome).toBe('skip')
151
+ expect(rec.notices).toHaveLength(0)
152
+ })
153
+
154
+ it('unresolved configured default → skip (never downgrade blind)', () => {
155
+ const { deps, rec } = makeDeps({ configuredDefault: '' })
156
+ const outcome = runTierDowngrade('klanker', deps)
157
+ expect(outcome).toBe('skip')
158
+ expect(rec.carrier).toHaveLength(0)
159
+ })
160
+
161
+ it('no agent dir → skip immediately', () => {
162
+ const { deps } = makeDeps({ agentDir: null })
163
+ expect(runTierDowngrade('klanker', deps)).toBe('skip')
164
+ })
165
+ })