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.
- package/dist/agent-scheduler/index.js +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1544 -619
- package/telegram-plugin/dist/server.js +32 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +399 -85
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +474 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/turn-flush-safety.ts +4 -3
- 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
|
-
|
|
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('
|
|
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
|
-
|
|
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\
|
|
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('
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
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
|
|
201
|
-
const chunks = splitMarkdownChunks(
|
|
197
|
+
const text = `${A}.\n\n${B}.`
|
|
198
|
+
const chunks = splitMarkdownChunks(text, 33)
|
|
202
199
|
expect(chunks.length).toBeGreaterThan(1)
|
|
203
|
-
const
|
|
200
|
+
const blankOnly = /^[ \t]*$/
|
|
204
201
|
for (const c of chunks) {
|
|
205
202
|
const lines = c.split('\n')
|
|
206
|
-
expect(
|
|
207
|
-
expect(
|
|
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
|
|
215
|
-
const chunks = splitMarkdownChunks(
|
|
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('
|
|
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
|
|
224
|
-
const
|
|
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(
|
|
223
|
+
const chunks = splitMarkdownChunks(text, cap)
|
|
227
224
|
for (const c of chunks) {
|
|
228
225
|
const lines = c.split('\n')
|
|
229
|
-
expect(
|
|
230
|
-
expect(
|
|
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
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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
|
+
})
|