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.
Files changed (54) hide show
  1. package/dist/cli/switchroom.js +905 -758
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_base/start.sh.hbs +111 -34
  5. package/skills/switchroom-runtime/SKILL.md +2 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1403 -657
  7. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  8. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  9. package/telegram-plugin/gateway/boot-card.ts +27 -0
  10. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  11. package/telegram-plugin/gateway/gateway.ts +564 -85
  12. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  13. package/telegram-plugin/gateway/model-command.ts +23 -11
  14. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  15. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  16. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  17. package/telegram-plugin/hooks/hooks.json +10 -10
  18. package/telegram-plugin/hooks/run-hook.sh +84 -0
  19. package/telegram-plugin/model-unavailable.ts +26 -0
  20. package/telegram-plugin/pty-partial-handler.ts +39 -0
  21. package/telegram-plugin/render/rich-render.ts +79 -1
  22. package/telegram-plugin/retry-api-call.ts +62 -0
  23. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  24. package/telegram-plugin/silence-poke.ts +14 -0
  25. package/telegram-plugin/stream-controller.ts +156 -38
  26. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  27. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  28. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  29. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  30. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  31. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  32. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  33. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  34. package/telegram-plugin/tests/model-command.test.ts +2 -2
  35. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  36. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  38. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  39. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  40. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  41. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  42. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  43. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  44. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  45. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  46. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  48. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  50. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  51. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  52. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  53. package/telegram-plugin/voice-ondemand.ts +25 -1
  54. 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 and the store is still emptied", async () => {
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 — still non-fatal, store still emptied.
220
- expect(res).toEqual({ cleared: 1, total: 2 });
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
+ });