switchroom 0.18.7 → 0.18.8
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/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +1403 -657
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/gateway.ts +564 -85
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable session-model file helpers (session-model-file.ts) — the gateway
|
|
3
|
+
* side of the stickiness contract (reference/rfcs/session-model-stickiness.md).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
7
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import {
|
|
11
|
+
serializeSessionModel,
|
|
12
|
+
parseSessionModel,
|
|
13
|
+
writeSessionModelFile,
|
|
14
|
+
readSessionModelFile,
|
|
15
|
+
readSessionModelFileRaw,
|
|
16
|
+
restoreSessionModelFileRaw,
|
|
17
|
+
clearSessionModelFile,
|
|
18
|
+
writeRelaunchModelIntent,
|
|
19
|
+
clearRelaunchModelIntent,
|
|
20
|
+
readConfiguredDefaultModel,
|
|
21
|
+
intentForRestartReason,
|
|
22
|
+
SESSION_MODEL_FILE,
|
|
23
|
+
RELAUNCH_MODEL_INTENT_FILE,
|
|
24
|
+
CONFIGURED_DEFAULT_MODEL_FILE,
|
|
25
|
+
} from '../gateway/session-model-file.js'
|
|
26
|
+
|
|
27
|
+
let dir: string
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), 'switchroom-sm-file-'))
|
|
30
|
+
})
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('serialize/parse round-trip', () => {
|
|
36
|
+
it('round-trips a record', () => {
|
|
37
|
+
const rec = { model: 'sr-glm-5', configuredDefaultAtWrite: 'claude-sonnet-5', ts: 1783948123456 }
|
|
38
|
+
expect(parseSessionModel(serializeSessionModel(rec))).toEqual(rec)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects corrupt JSON, missing fields, and non-canonical model tokens', () => {
|
|
42
|
+
expect(parseSessionModel('{broken')).toBeNull()
|
|
43
|
+
expect(parseSessionModel('{"model":"opus"}')).toBeNull()
|
|
44
|
+
expect(parseSessionModel('{"model":"Opus 4.8","configuredDefaultAtWrite":"x","ts":1}')).toBeNull()
|
|
45
|
+
expect(parseSessionModel('{"model":42,"configuredDefaultAtWrite":"x","ts":1}')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('writeSessionModelFile — canonical-token guard (review finding 7)', () => {
|
|
50
|
+
it('writes a canonical token with the current default + a fresh ts', () => {
|
|
51
|
+
writeSessionModelFile(dir, 'claude-opus-4-8', 'claude-sonnet-5')
|
|
52
|
+
const rec = readSessionModelFile(dir)!
|
|
53
|
+
expect(rec.model).toBe('claude-opus-4-8')
|
|
54
|
+
expect(rec.configuredDefaultAtWrite).toBe('claude-sonnet-5')
|
|
55
|
+
expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('THROWS on a display label — "Opus 4.5" must never be persisted', () => {
|
|
59
|
+
expect(() => writeSessionModelFile(dir, 'Opus 4.5', 'claude-sonnet-5')).toThrow(/non-canonical/)
|
|
60
|
+
expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('rollback snapshot (scheduleModelRelaunch dispatch failure)', () => {
|
|
65
|
+
it('restores prior content when a file existed', () => {
|
|
66
|
+
writeSessionModelFile(dir, 'claude-opus-4-8', 'claude-sonnet-5')
|
|
67
|
+
const snapshot = readSessionModelFileRaw(dir)
|
|
68
|
+
writeSessionModelFile(dir, 'sr-glm-5', 'claude-sonnet-5')
|
|
69
|
+
restoreSessionModelFileRaw(dir, snapshot)
|
|
70
|
+
expect(readSessionModelFile(dir)!.model).toBe('claude-opus-4-8')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('deletes the file when there was none before', () => {
|
|
74
|
+
const snapshot = readSessionModelFileRaw(dir) // null
|
|
75
|
+
writeSessionModelFile(dir, 'sr-glm-5', 'claude-sonnet-5')
|
|
76
|
+
restoreSessionModelFileRaw(dir, snapshot)
|
|
77
|
+
expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('relaunch intent', () => {
|
|
82
|
+
it('writes one-line JSON with intent, reason, and embedded ts (the freshness clock)', () => {
|
|
83
|
+
writeRelaunchModelIntent(dir, 'keep', 'user: /new from chat')
|
|
84
|
+
const raw = readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')
|
|
85
|
+
const parsed = JSON.parse(raw)
|
|
86
|
+
expect(parsed.intent).toBe('keep')
|
|
87
|
+
expect(parsed.reason).toBe('user: /new from chat')
|
|
88
|
+
expect(Math.abs(Date.now() - parsed.ts)).toBeLessThan(5000)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('last-writer-wins and clearable', () => {
|
|
92
|
+
writeRelaunchModelIntent(dir, 'keep', 'a')
|
|
93
|
+
writeRelaunchModelIntent(dir, 'revert', 'b')
|
|
94
|
+
expect(JSON.parse(readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')).intent).toBe('revert')
|
|
95
|
+
clearRelaunchModelIntent(dir)
|
|
96
|
+
expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('intentForRestartReason — the triggerSelfRestart per-reason table (RFC §3)', () => {
|
|
101
|
+
it.each([
|
|
102
|
+
'schedule-restart-immediate',
|
|
103
|
+
'restart-drain-cap-forced',
|
|
104
|
+
'turn-complete-pending-restart',
|
|
105
|
+
'fleet-fallback-resume',
|
|
106
|
+
'sr-to-claude-model-switch',
|
|
107
|
+
])('switchroom-managed relaunch %s → keep', (reason) => {
|
|
108
|
+
expect(intentForRestartReason(reason)).toBe('keep')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('inline-button-restart (operator-deliberate) → revert', () => {
|
|
112
|
+
expect(intentForRestartReason('inline-button-restart')).toBe('revert')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
|
|
116
|
+
expect(intentForRestartReason('some-future-recovery-path')).toBe('keep')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('readConfiguredDefaultModel', () => {
|
|
121
|
+
it('reads the trimmed value; null when absent or empty', () => {
|
|
122
|
+
expect(readConfiguredDefaultModel(dir)).toBeNull()
|
|
123
|
+
writeFileSync(join(dir, CONFIGURED_DEFAULT_MODEL_FILE), 'claude-sonnet-5\n')
|
|
124
|
+
expect(readConfiguredDefaultModel(dir)).toBe('claude-sonnet-5')
|
|
125
|
+
writeFileSync(join(dir, CONFIGURED_DEFAULT_MODEL_FILE), '\n')
|
|
126
|
+
expect(readConfiguredDefaultModel(dir)).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('clearSessionModelFile is a safe no-op when absent', () => {
|
|
130
|
+
expect(() => clearSessionModelFile(dir)).not.toThrow()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -170,7 +170,7 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
|
|
|
170
170
|
const gw2 = makeGateway(fs, tg);
|
|
171
171
|
const res = await gw2.bootCleanup();
|
|
172
172
|
|
|
173
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
173
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
174
174
|
expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(false); // orphan unpinned
|
|
175
175
|
expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
|
|
176
176
|
});
|
|
@@ -220,7 +220,7 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
|
|
|
220
220
|
// Fresh boot recovers it from the pending record.
|
|
221
221
|
const gw2 = makeGateway(fs, tg);
|
|
222
222
|
const res = await gw2.bootCleanup();
|
|
223
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
223
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
224
224
|
expect(tg.pinned.has(`${OWNER}:${rec[0].messageId}`)).toBe(false);
|
|
225
225
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
226
226
|
});
|
|
@@ -241,6 +241,6 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
|
|
|
241
241
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
242
242
|
|
|
243
243
|
const gw2 = makeGateway(fs, tg);
|
|
244
|
-
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
|
|
244
|
+
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
|
|
245
245
|
});
|
|
246
246
|
});
|
|
@@ -141,7 +141,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
141
141
|
const gw2 = makeGateway(fs, tg);
|
|
142
142
|
const res = await gw2.bootCleanup();
|
|
143
143
|
|
|
144
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
144
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
145
145
|
expect(tg.pinned.has("-100123:715")).toBe(false); // orphan unpinned
|
|
146
146
|
expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
|
|
147
147
|
});
|
|
@@ -177,7 +177,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
177
177
|
// Fresh boot recovers it from the pending record.
|
|
178
178
|
const gw2 = makeGateway(fs, tg);
|
|
179
179
|
const res = await gw2.bootCleanup();
|
|
180
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
180
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
181
181
|
expect(tg.pinned.has("-100123:715")).toBe(false);
|
|
182
182
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
183
183
|
});
|
|
@@ -196,7 +196,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
196
196
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
197
197
|
|
|
198
198
|
const gw2 = makeGateway(fs, tg);
|
|
199
|
-
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
|
|
199
|
+
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
202
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
BOOT_UNPIN_MAX_ATTEMPTS,
|
|
3
4
|
loadStatusPins,
|
|
4
5
|
mutateStatusPinRow,
|
|
5
6
|
persistStatusPins,
|
|
@@ -195,12 +196,12 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
195
196
|
["-100123", 715],
|
|
196
197
|
["-100999", 42],
|
|
197
198
|
]);
|
|
198
|
-
expect(res).toEqual({ cleared: 2, total: 2 });
|
|
199
|
+
expect(res).toEqual({ cleared: 2, retained: 0, kept: 0, total: 2 });
|
|
199
200
|
// Store empty afterwards → no re-attempt next boot.
|
|
200
201
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
201
202
|
});
|
|
202
203
|
|
|
203
|
-
it("a failing unpin is non-fatal
|
|
204
|
+
it("retry-safe (#3001): a failing unpin is non-fatal, RETAINS the row with an attempt counter, and drops only the succeeded one", async () => {
|
|
204
205
|
const { fs } = memFs();
|
|
205
206
|
persistStatusPins(PATH, fs, [
|
|
206
207
|
pin({ pinKey: "fg:c:1", chatId: "-100123", messageId: 5 }),
|
|
@@ -216,11 +217,66 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
216
217
|
log: () => {},
|
|
217
218
|
});
|
|
218
219
|
|
|
219
|
-
// One failed, one succeeded —
|
|
220
|
-
|
|
220
|
+
// One failed, one succeeded — the failure is retained for a next-boot
|
|
221
|
+
// retry instead of forfeiting the orphan (the pre-#3001 behaviour).
|
|
222
|
+
expect(res).toEqual({ cleared: 1, retained: 1, kept: 0, total: 2 });
|
|
223
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
224
|
+
{ pinKey: "fg:c:1", chatId: "-100123", messageId: 5, attempts: 1 },
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("retry-safe (#3001): a row is forfeited once its attempts reach BOOT_UNPIN_MAX_ATTEMPTS", async () => {
|
|
229
|
+
const { fs } = memFs();
|
|
230
|
+
persistStatusPins(PATH, fs, [
|
|
231
|
+
pin({
|
|
232
|
+
pinKey: "fg:c:1",
|
|
233
|
+
chatId: "-100123",
|
|
234
|
+
messageId: 5,
|
|
235
|
+
attempts: BOOT_UNPIN_MAX_ATTEMPTS - 1,
|
|
236
|
+
}),
|
|
237
|
+
]);
|
|
238
|
+
const res = await runStatusPinBootCleanup({
|
|
239
|
+
path: PATH,
|
|
240
|
+
fs,
|
|
241
|
+
unpin: async () => {
|
|
242
|
+
throw new Error("chat gone forever");
|
|
243
|
+
},
|
|
244
|
+
log: () => {},
|
|
245
|
+
});
|
|
246
|
+
// Final attempt failed too — forfeited, not retained: a permanently-
|
|
247
|
+
// undeliverable unpin must not re-fail on every future boot.
|
|
248
|
+
expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 1 });
|
|
221
249
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
222
250
|
});
|
|
223
251
|
|
|
252
|
+
it("tool pins (#3001): an UNEXPIRED `tool:` row survives the boot untouched; an EXPIRED one is unpinned and dropped", async () => {
|
|
253
|
+
const { fs } = memFs();
|
|
254
|
+
const now = 1_750_000_000_000;
|
|
255
|
+
persistStatusPins(PATH, fs, [
|
|
256
|
+
pin({ pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 }),
|
|
257
|
+
pin({ pinKey: "tool:-100123:71", chatId: "-100123", messageId: 71, expiresAt: now }),
|
|
258
|
+
pin({ pinKey: "wk:agent-x", chatId: "-100123", messageId: 72 }),
|
|
259
|
+
]);
|
|
260
|
+
const unpinned: number[] = [];
|
|
261
|
+
const res = await runStatusPinBootCleanup({
|
|
262
|
+
path: PATH,
|
|
263
|
+
fs,
|
|
264
|
+
unpin: async (_c, messageId) => {
|
|
265
|
+
unpinned.push(messageId);
|
|
266
|
+
},
|
|
267
|
+
now,
|
|
268
|
+
log: () => {},
|
|
269
|
+
});
|
|
270
|
+
// The expired tool pin and the work-scoped wk: pin are unpinned; the
|
|
271
|
+
// unexpired tool pin is kept for a future boot (restart ≠ reset for a
|
|
272
|
+
// deliberate agent pin with no "work finished" event).
|
|
273
|
+
expect(unpinned).toEqual([71, 72]);
|
|
274
|
+
expect(res).toEqual({ cleared: 2, retained: 0, kept: 1, total: 3 });
|
|
275
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
276
|
+
{ pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 },
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
224
280
|
it("no-op on a fresh boot with no persisted pins (no unpin calls)", async () => {
|
|
225
281
|
const { fs } = memFs();
|
|
226
282
|
let calls = 0;
|
|
@@ -232,7 +288,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
232
288
|
},
|
|
233
289
|
log: () => {},
|
|
234
290
|
});
|
|
235
|
-
expect(res).toEqual({ cleared: 0, total: 0 });
|
|
291
|
+
expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
|
|
236
292
|
expect(calls).toBe(0);
|
|
237
293
|
});
|
|
238
294
|
|
|
@@ -254,7 +310,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
254
310
|
log: () => {},
|
|
255
311
|
});
|
|
256
312
|
expect(unpinned).toEqual([["-100777", 314]]);
|
|
257
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
313
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
258
314
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
259
315
|
});
|
|
260
316
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level regression test for the chunk-boundary cap bug
|
|
3
|
+
* (fix/rich-render-chunk-boundary-cap).
|
|
4
|
+
*
|
|
5
|
+
* With `SWITCHROOM_RICH_RENDER` on, a near-cap body full of escapable chars
|
|
6
|
+
* (`_ * |`) makes `renderSafe` degrade the whole document to plain (its escaped
|
|
7
|
+
* rich form exceeds RICH_MESSAGE_MAX_CHARS). BEFORE the fix, the stream
|
|
8
|
+
* controller shipped that ~32k plain body through the plain `sendMessage`
|
|
9
|
+
* endpoint in ONE call — Telegram's plain endpoint caps at 4096, so the send
|
|
10
|
+
* is rejected (`message is too long`) and the streamed answer is dropped.
|
|
11
|
+
*
|
|
12
|
+
* AFTER the fix, `renderOutboundChunks` re-splits at safe boundaries so every
|
|
13
|
+
* emitted send fits its own wire cap: rich pieces <= 32768, plain pieces
|
|
14
|
+
* <= 4096, and no fenced block is bisected.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
17
|
+
import { createStreamController } from "../stream-controller.js";
|
|
18
|
+
import { createFakeBotApi } from "./fake-bot-api.js";
|
|
19
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
20
|
+
|
|
21
|
+
const PLAIN_CAP = 4096; // Telegram's legacy plain-text sendMessage cap (format.ts:29).
|
|
22
|
+
|
|
23
|
+
function fenceCount(s: string): number {
|
|
24
|
+
return (s.match(/^```/gm) ?? []).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("stream-controller enforces the wire cap on the post-escape body", () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
delete process.env.SWITCHROOM_RICH_RENDER;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("REGRESSION: near-cap escapable first send never exceeds the plain wire cap", async () => {
|
|
33
|
+
process.env.SWITCHROOM_RICH_RENDER = "1";
|
|
34
|
+
const bot = createFakeBotApi({ startMessageId: 1000 });
|
|
35
|
+
const unit = "a_b*c|d ";
|
|
36
|
+
const body = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
37
|
+
|
|
38
|
+
const stream = createStreamController({
|
|
39
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
40
|
+
chatId: "c1",
|
|
41
|
+
throttleMs: 0,
|
|
42
|
+
});
|
|
43
|
+
await stream.update(body);
|
|
44
|
+
await stream.finalize();
|
|
45
|
+
|
|
46
|
+
expect(bot.state.sent.length).toBeGreaterThan(0);
|
|
47
|
+
for (const s of bot.state.sent) {
|
|
48
|
+
// Every send fits the rich cap...
|
|
49
|
+
expect(s.text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
50
|
+
// ...and a PLAIN send (rich !== true) additionally fits the 4096 plain
|
|
51
|
+
// endpoint cap — the invariant HEAD violated (one ~32k plain send).
|
|
52
|
+
if (!s.rich) expect(s.text.length).toBeLessThanOrEqual(PLAIN_CAP);
|
|
53
|
+
// No send bisects a fenced block.
|
|
54
|
+
expect(fenceCount(s.text) % 2).toBe(0);
|
|
55
|
+
}
|
|
56
|
+
}, 30000);
|
|
57
|
+
|
|
58
|
+
it("BLOCKER: multi-update oversize stream emits tails ONCE, not per edit tick", async () => {
|
|
59
|
+
// Reproduces the duplicate-flood blocker: an oversize body splits into N
|
|
60
|
+
// pieces. The FIRST flush (send path) emits the anchor + (N-1) tail
|
|
61
|
+
// messages. Every SUBSEQUENT throttled flush routes through the EDIT
|
|
62
|
+
// callback. The pre-fix code re-sent all (N-1) tails as brand-new messages
|
|
63
|
+
// on each edit tick, so `sent.length` grew by (N-1) every update. After the
|
|
64
|
+
// fix, tails are parked once and edited in place — `sent.length` is flat.
|
|
65
|
+
process.env.SWITCHROOM_RICH_RENDER = "1";
|
|
66
|
+
const bot = createFakeBotApi({ startMessageId: 3000 });
|
|
67
|
+
const unit = "a_b*c|d ";
|
|
68
|
+
// Near-cap body that degrades to plain and splits into several pieces.
|
|
69
|
+
const base = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
70
|
+
// Distinct short prefix per flush so the HEAD piece actually changes —
|
|
71
|
+
// an unchanged head yields a not-modified edit, which short-circuits before
|
|
72
|
+
// the tail loop and would hide the duplicate-resend bug.
|
|
73
|
+
const bodyFor = (i: number) => `v${i} ${base}`;
|
|
74
|
+
|
|
75
|
+
const stream = createStreamController({
|
|
76
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
77
|
+
chatId: "c1",
|
|
78
|
+
throttleMs: 0,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// First flush → send path: anchor + tails, emitted exactly once.
|
|
82
|
+
await stream.update(bodyFor(1));
|
|
83
|
+
const afterFirst = bot.state.sent.length;
|
|
84
|
+
expect(afterFirst).toBeGreaterThan(1); // genuinely multi-piece
|
|
85
|
+
|
|
86
|
+
// Drive several more oversize updates — each routes through the EDIT
|
|
87
|
+
// callback. The count must NOT grow: tails are edited in place, not resent.
|
|
88
|
+
await stream.update(bodyFor(2));
|
|
89
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
90
|
+
await stream.update(bodyFor(3));
|
|
91
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
92
|
+
await stream.update(bodyFor(4));
|
|
93
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
94
|
+
await stream.finalize();
|
|
95
|
+
|
|
96
|
+
// Final: exactly the anchor + tail set, no duplicates across the lifetime.
|
|
97
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
98
|
+
const ids = bot.state.sent.map((s) => s.message_id);
|
|
99
|
+
expect(new Set(ids).size).toBe(ids.length); // no duplicate message ids
|
|
100
|
+
|
|
101
|
+
// Every currently-visible message fits its wire cap and (for plain) 4096.
|
|
102
|
+
for (const s of bot.state.sent) {
|
|
103
|
+
const cur = bot.textOf(s.message_id) ?? "";
|
|
104
|
+
expect(cur.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
105
|
+
if (!s.rich) expect(cur.length).toBeLessThanOrEqual(PLAIN_CAP);
|
|
106
|
+
}
|
|
107
|
+
}, 30000);
|
|
108
|
+
|
|
109
|
+
it("flag OFF leaves the single-send path untouched", async () => {
|
|
110
|
+
const bot = createFakeBotApi({ startMessageId: 2000 });
|
|
111
|
+
const stream = createStreamController({
|
|
112
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
113
|
+
chatId: "c1",
|
|
114
|
+
throttleMs: 0,
|
|
115
|
+
});
|
|
116
|
+
await stream.update("**hi** _there_");
|
|
117
|
+
await stream.finalize();
|
|
118
|
+
expect(bot.state.sent).toHaveLength(1);
|
|
119
|
+
expect(bot.state.sent[0].rich).toBe(true);
|
|
120
|
+
expect(bot.state.sent[0].text).toBe("**hi** _there_");
|
|
121
|
+
});
|
|
122
|
+
});
|