switchroom 0.18.7 → 0.18.9

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 (85) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +905 -758
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_base/start.sh.hbs +111 -34
  6. package/skills/switchroom-runtime/SKILL.md +2 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
  8. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  9. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  10. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  11. package/telegram-plugin/gateway/boot-card.ts +27 -0
  12. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  13. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  14. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  15. package/telegram-plugin/gateway/gateway.ts +1169 -3043
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  17. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  18. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  19. package/telegram-plugin/gateway/model-command.ts +23 -11
  20. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  21. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  22. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  23. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  24. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  25. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  26. package/telegram-plugin/hooks/hooks.json +10 -10
  27. package/telegram-plugin/hooks/run-hook.sh +84 -0
  28. package/telegram-plugin/model-unavailable.ts +26 -0
  29. package/telegram-plugin/pty-partial-handler.ts +39 -0
  30. package/telegram-plugin/render/rich-render.ts +79 -1
  31. package/telegram-plugin/retry-api-call.ts +62 -0
  32. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  33. package/telegram-plugin/silence-poke.ts +14 -0
  34. package/telegram-plugin/stream-controller.ts +156 -38
  35. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  36. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  37. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  38. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  39. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  40. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  41. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  42. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  43. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  44. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  45. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  46. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  47. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  48. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  49. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  50. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  51. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  52. package/telegram-plugin/tests/model-command.test.ts +2 -2
  53. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  54. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  55. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  56. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  57. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  58. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  59. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  60. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  61. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  62. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  65. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  67. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  68. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  69. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  70. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  71. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  72. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  73. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  74. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  75. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  76. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  77. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  78. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  79. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  80. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  81. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  82. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  83. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  84. package/telegram-plugin/voice-ondemand.ts +25 -1
  85. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Regression coverage for #2976 — HTML-entity corruption in mental-model
3
+ * fields, config-write-boundary layer.
4
+ *
5
+ * Reachability (stated plainly so these tests don't over-claim):
6
+ * - `source_query` decode is the REACHABLE/load-bearing half: a proposal's
7
+ * free-form query can carry escaped entities the model copied out of its
8
+ * Telegram-HTML context, and undecoded it steers recall on `R&D`
9
+ * instead of `R&D`.
10
+ * - `name` decode is REDUNDANT with the gateway slug gate
11
+ * (/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ rejects an entity-bearing name before
12
+ * `buildMentalModelAppendDiff` ever runs). It's belt-and-suspenders; the
13
+ * name-path tests here exercise the boundary directly (bypassing the gate)
14
+ * to prove the normalization, NOT to imply the propose path could carry the
15
+ * bug.
16
+ * - The observed in-NAME corruption (`Nutrition Protocol & Deficit
17
+ * Status`, klanker 2026-07-06) actually arrived via the DIRECT
18
+ * `create_mental_model` Hindsight tool, which this PR does not touch — that
19
+ * vector is out of scope (steering.ts / Dockerfile.hindsight follow-ups).
20
+ *
21
+ * These tests pin the switchroom-owned WRITE-BOUNDARY normalization: name +
22
+ * source_query are decoded to their literal characters BEFORE synthesis into
23
+ * the config diff. Each assertion below FAILS against the pre-fix code (which
24
+ * serialized `spec.name` / `spec.source_query` verbatim) and passes after.
25
+ */
26
+
27
+ import { describe, it, expect } from "vitest";
28
+ import { parseDocument } from "yaml";
29
+ import {
30
+ buildMentalModelAppendDiff,
31
+ readDeclaredMentalModelNames,
32
+ decodeCanonicalEntities,
33
+ } from "../gateway/mental-model-propose-diff.js";
34
+
35
+ const BASE_CONFIG = `agents:
36
+ klanker:
37
+ memory:
38
+ backend: hindsight
39
+ `;
40
+
41
+ /** Pull the appended model node out of the synthesized `after` config. */
42
+ function appendedModel(after: string, agent = "klanker"): { name?: unknown; source_query?: unknown } {
43
+ const js = parseDocument(after).toJS() as {
44
+ agents: Record<string, { memory: { mental_models: Array<Record<string, unknown>> } }>;
45
+ };
46
+ const models = js.agents[agent]!.memory.mental_models;
47
+ return models[models.length - 1]!;
48
+ }
49
+
50
+ describe("decodeCanonicalEntities — single-pass, canonical six only", () => {
51
+ it("decodes each canonical entity to its literal character", () => {
52
+ expect(decodeCanonicalEntities("R&amp;D")).toBe("R&D");
53
+ expect(decodeCanonicalEntities("a&lt;b&gt;c")).toBe("a<b>c");
54
+ expect(decodeCanonicalEntities("say &quot;hi&quot;")).toBe('say "hi"');
55
+ expect(decodeCanonicalEntities("Ken&apos;s")).toBe("Ken's");
56
+ expect(decodeCanonicalEntities("Ken&#39;s")).toBe("Ken's");
57
+ });
58
+
59
+ it("undoes exactly ONE layer of escaping (double-escaped → single-escaped)", () => {
60
+ // We only reverse the escaping switchroom applied when rendering the model's
61
+ // own prior output — never the user's literal intent.
62
+ expect(decodeCanonicalEntities("R&amp;amp;D")).toBe("R&amp;D");
63
+ expect(decodeCanonicalEntities("&amp;lt;")).toBe("&lt;");
64
+ });
65
+
66
+ it("leaves entity-free text untouched (idempotent on clean input)", () => {
67
+ expect(decodeCanonicalEntities("Nutrition Protocol & Deficit")).toBe("Nutrition Protocol & Deficit");
68
+ });
69
+ });
70
+
71
+ describe("#2976 — propose write-boundary normalizes the mental-model name", () => {
72
+ it("stores the LITERAL '&' name, not the escaped '&amp;' the model emitted", () => {
73
+ const built = buildMentalModelAppendDiff({
74
+ configText: BASE_CONFIG,
75
+ agentName: "klanker",
76
+ spec: { name: "Nutrition Protocol &amp; Deficit Status", source_query: "how is the deficit?" },
77
+ });
78
+ expect(built.ok).toBe(true);
79
+ if (!built.ok) return;
80
+ expect(appendedModel(built.after).name).toBe("Nutrition Protocol & Deficit Status");
81
+ // And there is no residual entity anywhere in the persisted config.
82
+ expect(built.after).not.toContain("&amp;");
83
+ // Reading the declared names back yields the decoded literal.
84
+ expect(readDeclaredMentalModelNames(built.after, "klanker")).toContain(
85
+ "Nutrition Protocol & Deficit Status",
86
+ );
87
+ });
88
+
89
+ it("normalizes the recall-steering source_query too (not just the name)", () => {
90
+ const built = buildMentalModelAppendDiff({
91
+ configText: BASE_CONFIG,
92
+ agentName: "klanker",
93
+ spec: { name: "rd-budget", source_query: "what is the user&apos;s R&amp;D budget?" },
94
+ });
95
+ expect(built.ok).toBe(true);
96
+ if (!built.ok) return;
97
+ expect(appendedModel(built.after).source_query).toBe("what is the user's R&D budget?");
98
+ });
99
+
100
+ it("rejects an entity-escaped re-propose of an already-declared literal name (dup guard on decoded name)", () => {
101
+ const configWithModel = `agents:
102
+ klanker:
103
+ memory:
104
+ backend: hindsight
105
+ mental_models:
106
+ - name: Q3 R&D Plan
107
+ source_query: what is the plan?
108
+ `;
109
+ const built = buildMentalModelAppendDiff({
110
+ configText: configWithModel,
111
+ agentName: "klanker",
112
+ // Escaped variant of the same logical name — must collide, not create a twin.
113
+ spec: { name: "Q3 R&amp;D Plan", source_query: "different query" },
114
+ });
115
+ expect(built.ok).toBe(false);
116
+ if (built.ok) return;
117
+ expect(built.error).toBe("duplicate");
118
+ });
119
+ });
@@ -19,7 +19,14 @@ import { describe, expect, it } from 'vitest'
19
19
  import { readFileSync } from 'node:fs'
20
20
  import { resolve } from 'node:path'
21
21
 
22
- const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
22
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
23
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
24
+ // COMBINED with that module so the wiring assertions keep covering the
25
+ // same runtime source text.
26
+ const gatewaySrc =
27
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
28
+ '\n' +
29
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
23
30
 
24
31
  function proposeCallbackBlock(): string {
25
32
  return (
@@ -201,7 +201,7 @@ describe("handleModelCommand — set", () => {
201
201
  const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
202
202
  expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
203
203
  expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
204
- expect(reply.text).toContain("Session-only");
204
+ expect(reply.text).toContain("Sticky across switchroom-managed relaunches");
205
205
  expect(reply.html).toBe(true);
206
206
  // A verified confirmation records the live model so /status stays honest
207
207
  // (bug 1: the typed path never recorded the switch before).
@@ -444,7 +444,7 @@ describe("handleModelCommand — busy gate + honest unverified reporting", () =>
444
444
  const reply = await handleModelCommand({ kind: "set", model: "claude-bogus" }, deps);
445
445
  expect(reply.text).toContain("did not take");
446
446
  expect(reply.text).toContain("Model not found");
447
- expect(reply.text).not.toContain("Session-only");
447
+ expect(reply.text).not.toContain("Sticky across switchroom-managed relaunches");
448
448
  expect(reply.selectedModel).toBeUndefined();
449
449
  });
450
450
 
@@ -76,6 +76,47 @@ describe('detectModelUnavailable — overload / 429 / 5xx strings', () => {
76
76
  })
77
77
  })
78
78
 
79
+ describe('detectModelUnavailable — transient upstream 429 vs account quota (#2922)', () => {
80
+ // The load-bearing regression: a server-side transient 429 whose message
81
+ // explicitly negates the account-quota reading ("not your usage limit")
82
+ // was misclassified as `quota_exhausted` by the negation-blind "usage limit"
83
+ // substring, firing a phantom fleet failover + dead turn. It must classify
84
+ // as `overload` (the calm rate-limit path Claude Code retries internally).
85
+ it("classifies the live-incident 'not your usage limit' 429 as overload, NOT quota_exhausted", () => {
86
+ const raw =
87
+ "API Error: Server is temporarily limiting requests (not your usage limit) · " +
88
+ 'b\'{"type":"error","error":{"type":"rate_limit_error","message":' +
89
+ '"This request would exceed your account\'s rate limit. Please try again later."}}\''
90
+ const d = detectModelUnavailable(raw)
91
+ expect(d?.kind).toBe('overload')
92
+ expect(d?.kind).not.toBe('quota_exhausted')
93
+ })
94
+
95
+ it("classifies bare 'temporarily limiting requests' as overload", () => {
96
+ expect(
97
+ detectModelUnavailable('Server is temporarily limiting requests')?.kind,
98
+ ).toBe('overload')
99
+ })
100
+
101
+ it("classifies \"would exceed your account's rate limit\" as overload", () => {
102
+ expect(
103
+ detectModelUnavailable(
104
+ "This request would exceed your account's rate limit. Please try again later.",
105
+ )?.kind,
106
+ ).toBe('overload')
107
+ })
108
+
109
+ it('still classifies a genuine account usage-limit hit as quota_exhausted', () => {
110
+ // Guard against over-correction: real quota exhaustion must stay quota.
111
+ expect(
112
+ detectModelUnavailable("You've hit your limit · resets 8:50am (Australia/Melbourne)")?.kind,
113
+ ).toBe('quota_exhausted')
114
+ expect(detectModelUnavailable('Reached usage limit for the 5h window')?.kind).toBe(
115
+ 'quota_exhausted',
116
+ )
117
+ })
118
+ })
119
+
79
120
  describe('detectModelUnavailable — network failures', () => {
80
121
  it('classifies ECONNREFUSED', () => {
81
122
  expect(detectModelUnavailable('connect ECONNREFUSED 1.2.3.4:443')?.kind).toBe('network')
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Invocable send-loop harness (#2996 step 1).
3
+ *
4
+ * PR 3007 extracted the deterministic text/chunk CORE of the outbound path but
5
+ * noted the side-effecting send ORCHESTRATION could not be moved (or tested)
6
+ * without an "invocable-executeReply harness" — because gateway.ts is not
7
+ * importable in any test runner: it runs boot logic (acquires the PID lock,
8
+ * `process.exit(1)` when another gateway is live) and calls `Bun.listen` at
9
+ * import time, so `executeReply` can never be driven from vitest/bun in-place.
10
+ *
11
+ * This harness closes that gap for the highest-bug-density mechanic of the send
12
+ * path — the chunk-send loop with its THREAD_NOT_FOUND / oversize re-split /
13
+ * parse-reject fallback ladder and partial-failure contract (exactly where the
14
+ * recent oversize / wire-cap fixes landed). `sendReplyChunks` was relocated
15
+ * VERBATIM from executeReply and is now driven here against a FAKE bot API,
16
+ * asserting: multi-chunk send order, partial-failure contract (which chunks
17
+ * report sent), oversize re-send, HTML parse-reject plaintext fallback,
18
+ * THREAD_NOT_FOUND thread-drop + retry, keyboard-on-last-chunk passthrough,
19
+ * preview edit-in-place, and voice-only text suppression.
20
+ *
21
+ * The gateway keeps the raw `bot.api.*` calls as thin injected adapters; the
22
+ * module is bot-agnostic, so a fake is a plain record of function calls. The
23
+ * cross-surface `outboundDedup` singleton contract is covered by the sibling
24
+ * `outbound-send-path.test.ts` (a stream-path `record` suppresses a reply-path
25
+ * `check` on the same instance) — dedup lives in executeReply, not in this
26
+ * relocated loop, so it is intentionally out of scope here.
27
+ */
28
+ import { describe, it, expect } from 'vitest'
29
+ import { GrammyError } from 'grammy'
30
+ import {
31
+ sendReplyChunks,
32
+ type ReplyChunkSendDeps,
33
+ type ReplyChunkSendState,
34
+ } from '../gateway/outbound-send-path.js'
35
+
36
+ // Build a GrammyError with a given 400 description (grammy 1.44 ctor shape).
37
+ function grammy400(description: string, method = 'sendRichMessage'): GrammyError {
38
+ return new GrammyError(
39
+ `Call to '${method}' failed! (400: ${description})`,
40
+ { ok: false, error_code: 400, description },
41
+ method,
42
+ {},
43
+ )
44
+ }
45
+
46
+ interface RecordedCall {
47
+ kind: 'sendRich' | 'sendLiteral' | 'sendLiteralRaw' | 'sendRichRaw' | 'editPreview'
48
+ opts: Record<string, unknown>
49
+ body: unknown
50
+ threadId?: number | undefined
51
+ messageId?: number
52
+ }
53
+
54
+ /**
55
+ * Fake bot surface. `sendRich`/`sendLiteral` are the retry-wrapped adapters;
56
+ * `*Raw` are the unwrapped last-resort adapters; `editPreview` is the
57
+ * preview edit-in-place. Each records its call; per-kind `failers` inject
58
+ * one-shot errors (keyed by chunk-body identity or call index) so we can
59
+ * script the fallback ladder deterministically.
60
+ */
61
+ function makeFake(opts?: {
62
+ failNext?: Partial<Record<RecordedCall['kind'], Array<unknown>>>
63
+ richMessage?: (s: string) => unknown
64
+ }): {
65
+ deps: ReplyChunkSendDeps
66
+ calls: RecordedCall[]
67
+ stderr: string[]
68
+ logs: Array<{ id: number; chars: number; extra?: string }>
69
+ deleted: number[]
70
+ } {
71
+ const calls: RecordedCall[] = []
72
+ const stderr: string[] = []
73
+ const logs: Array<{ id: number; chars: number; extra?: string }> = []
74
+ const deleted: number[] = []
75
+ let nextId = 1000
76
+ const queues = opts?.failNext ?? {}
77
+
78
+ const maybeFail = (kind: RecordedCall['kind']): void => {
79
+ const q = queues[kind]
80
+ if (q && q.length > 0) {
81
+ const e = q.shift()
82
+ if (e != null) throw e
83
+ }
84
+ }
85
+ const send = (
86
+ kind: RecordedCall['kind'],
87
+ o: Record<string, unknown>,
88
+ body: unknown,
89
+ threadId?: number | undefined,
90
+ ): Promise<{ message_id: number }> => {
91
+ // Evaluate the failure queue synchronously so a thrown GrammyError
92
+ // rejects the returned promise exactly as the real adapter would.
93
+ return (async () => {
94
+ maybeFail(kind)
95
+ const id = ++nextId
96
+ calls.push({ kind, opts: o, body, threadId, messageId: id })
97
+ return { message_id: id }
98
+ })()
99
+ }
100
+
101
+ const deps: ReplyChunkSendDeps = {
102
+ sendRich: (o, body, tid) => send('sendRich', o, body, tid),
103
+ sendLiteral: (o, txt, tid) => send('sendLiteral', o, txt, tid),
104
+ sendLiteralRaw: (o, txt) => send('sendLiteralRaw', o, txt),
105
+ sendRichRaw: (o, body) => send('sendRichRaw', o, body),
106
+ editPreview: async (mid, body, o, tid) => {
107
+ maybeFail('editPreview')
108
+ calls.push({ kind: 'editPreview', opts: o, body, threadId: tid, messageId: mid })
109
+ return {}
110
+ },
111
+ richMessage: opts?.richMessage ?? ((s: string) => ({ rich: s })),
112
+ logOutbound: (_path, _chat, id, chars, extra) => { logs.push({ id, chars, extra }) },
113
+ deleteStalePreview: async (id) => { deleted.push(id) },
114
+ stderr: (s) => { stderr.push(s) },
115
+ }
116
+ return { deps, calls, stderr, logs, deleted }
117
+ }
118
+
119
+ function baseState(over: Partial<ReplyChunkSendState> & { chunks: string[] }): ReplyChunkSendState {
120
+ return {
121
+ chatId: '555',
122
+ literalText: false,
123
+ suppressText: false,
124
+ threadId: undefined,
125
+ previewMessageId: null,
126
+ sentIds: [],
127
+ buildSendOpts: (i, isLastChunk, tid) => ({
128
+ ...(tid != null ? { message_thread_id: tid } : {}),
129
+ ...(isLastChunk ? { _last: true } : {}),
130
+ _chunkIndex: i,
131
+ }),
132
+ buildPreviewEditOpts: () => ({}),
133
+ ...over,
134
+ }
135
+ }
136
+
137
+ describe('sendReplyChunks — multi-chunk send order', () => {
138
+ it('sends every chunk in order and appends ids to the shared sentIds array', async () => {
139
+ const { deps, calls } = makeFake()
140
+ const state = baseState({ chunks: ['a', 'b', 'c'] })
141
+ const res = await sendReplyChunks(deps, state)
142
+ expect(calls.map((c) => c.kind)).toEqual(['sendRich', 'sendRich', 'sendRich'])
143
+ expect(calls.map((c) => c.body)).toEqual([{ rich: 'a' }, { rich: 'b' }, { rich: 'c' }])
144
+ // ids appended in order, one per chunk
145
+ expect(state.sentIds).toHaveLength(3)
146
+ expect(state.sentIds).toEqual([...state.sentIds].sort((x, y) => x - y))
147
+ expect(res.threadId).toBeUndefined()
148
+ })
149
+
150
+ it('literal (format:text) chunks route through sendLiteral, never rich', async () => {
151
+ const { deps, calls } = makeFake()
152
+ const state = baseState({ chunks: ['x', 'y'], literalText: true })
153
+ await sendReplyChunks(deps, state)
154
+ expect(calls.map((c) => c.kind)).toEqual(['sendLiteral', 'sendLiteral'])
155
+ expect(calls.map((c) => c.body)).toEqual(['x', 'y'])
156
+ })
157
+
158
+ it('voice-only suppressText sends NOTHING (voice note IS the reply)', async () => {
159
+ const { deps, calls } = makeFake()
160
+ const state = baseState({ chunks: ['a', 'b'], suppressText: true })
161
+ await sendReplyChunks(deps, state)
162
+ expect(calls).toHaveLength(0)
163
+ expect(state.sentIds).toHaveLength(0)
164
+ })
165
+ })
166
+
167
+ describe('sendReplyChunks — keyboard/opts passthrough', () => {
168
+ it('reply_markup rides ONLY on the last chunk (isLastChunk gate)', async () => {
169
+ const { deps, calls } = makeFake()
170
+ const state = baseState({
171
+ chunks: ['one', 'two', 'three'],
172
+ buildSendOpts: (i, isLastChunk, tid) => ({
173
+ ...(tid != null ? { message_thread_id: tid } : {}),
174
+ ...(isLastChunk ? { reply_markup: { inline_keyboard: [[{ text: 'Go', callback_data: 'x' }]] } } : {}),
175
+ }),
176
+ })
177
+ await sendReplyChunks(deps, state)
178
+ expect(calls[0].opts.reply_markup).toBeUndefined()
179
+ expect(calls[1].opts.reply_markup).toBeUndefined()
180
+ expect(calls[2].opts.reply_markup).toBeDefined()
181
+ })
182
+
183
+ it('rich path strips link_preview_options before sending (rich entity previews)', async () => {
184
+ const { deps, calls } = makeFake()
185
+ const state = baseState({
186
+ chunks: ['only'],
187
+ buildSendOpts: () => ({ link_preview_options: { is_disabled: true }, keepme: 1 }),
188
+ })
189
+ await sendReplyChunks(deps, state)
190
+ expect(calls[0].opts.link_preview_options).toBeUndefined()
191
+ expect(calls[0].opts.keepme).toBe(1)
192
+ })
193
+ })
194
+
195
+ describe('sendReplyChunks — partial-failure contract', () => {
196
+ it('an unrecoverable error midway propagates, and sentIds holds ONLY the chunks already sent', async () => {
197
+ // chunk 0 ok, chunk 1 throws a non-400 (not length, not parse) → rethrow.
198
+ const boom = new Error('network exploded')
199
+ const { deps, calls } = makeFake({ failNext: { sendRich: [undefined, boom] } })
200
+ const state = baseState({ chunks: ['first', 'second', 'third'] })
201
+ await expect(sendReplyChunks(deps, state)).rejects.toThrow('network exploded')
202
+ // first chunk reported sent; second/third never appended.
203
+ expect(state.sentIds).toHaveLength(1)
204
+ // exactly one send succeeded (chunk 1 threw before being recorded).
205
+ expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(1)
206
+ })
207
+ })
208
+
209
+ describe('sendReplyChunks — oversize re-send', () => {
210
+ it('a RICH_MESSAGE_TEXT_TOO_LONG on the wrapped send re-splits via the raw rich adapter', async () => {
211
+ // A chunk far past the wire cap so resplitOversizeChunk yields >1 piece.
212
+ const huge = 'x'.repeat(70_000)
213
+ const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400('RICH_MESSAGE_TEXT_TOO_LONG')] } })
214
+ const state = baseState({ chunks: [huge] })
215
+ await sendReplyChunks(deps, state)
216
+ const raws = calls.filter((c) => c.kind === 'sendRichRaw')
217
+ expect(raws.length).toBeGreaterThan(1)
218
+ // every re-split piece landed a message id
219
+ expect(state.sentIds.length).toBe(raws.length)
220
+ })
221
+ })
222
+
223
+ describe('sendReplyChunks — parse-reject plaintext fallback', () => {
224
+ it("a can't-parse-entities 400 resends the chunk as plain text via sendLiteralRaw", async () => {
225
+ const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400("can't parse entities: bad offset")] } })
226
+ const state = baseState({ chunks: ['**bad markdown'] })
227
+ await sendReplyChunks(deps, state)
228
+ expect(calls.map((c) => c.kind)).toEqual(['sendLiteralRaw'])
229
+ expect(calls[0].body).toBe('**bad markdown')
230
+ expect(state.sentIds).toHaveLength(1)
231
+ })
232
+
233
+ it('an empty chunk parse-reject falls back to the placeholder glyph', async () => {
234
+ const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400("can't parse entities")] } })
235
+ const state = baseState({ chunks: [''] })
236
+ await sendReplyChunks(deps, state)
237
+ expect(calls[0].kind).toBe('sendLiteralRaw')
238
+ expect(String(calls[0].body)).toContain('could not be rendered')
239
+ })
240
+ })
241
+
242
+ describe('sendReplyChunks — THREAD_NOT_FOUND fallback', () => {
243
+ it('drops the thread and retries UNWRAPPED, and returns threadId undefined', async () => {
244
+ // wrapped send throws THREAD_NOT_FOUND; retry (raw) succeeds.
245
+ const tnf = new Error('THREAD_NOT_FOUND')
246
+ const { deps, calls } = makeFake({ failNext: { sendRich: [tnf] } })
247
+ const state = baseState({ chunks: ['hi'], threadId: 42 })
248
+ const res = await sendReplyChunks(deps, state)
249
+ // The first (wrapped) attempt threw before recording; the retry uses
250
+ // sendChunk(_, false) → the UNWRAPPED raw adapter, deliberately not
251
+ // re-entering the retry policy after a dropped thread.
252
+ expect(calls[0].kind).toBe('sendRichRaw')
253
+ // retry re-built opts WITHOUT the thread id.
254
+ expect(res.threadId).toBeUndefined()
255
+ expect(calls[0].opts.message_thread_id).toBeUndefined()
256
+ expect(state.sentIds).toHaveLength(1)
257
+ })
258
+
259
+ it('THREAD_NOT_FOUND then a length error on retry re-splits', async () => {
260
+ const tnf = new Error('THREAD_NOT_FOUND')
261
+ const huge = 'y'.repeat(70_000)
262
+ const { deps, calls } = makeFake({
263
+ failNext: { sendRich: [tnf], sendRichRaw: [grammy400('MESSAGE_TOO_LONG')] },
264
+ })
265
+ const state = baseState({ chunks: [huge], threadId: 7 })
266
+ await sendReplyChunks(deps, state)
267
+ // retry (raw) threw length → resplit sent multiple raw pieces
268
+ const raws = calls.filter((c) => c.kind === 'sendRichRaw')
269
+ expect(raws.length).toBeGreaterThan(1)
270
+ })
271
+ })
272
+
273
+ describe('sendReplyChunks — preview edit-in-place', () => {
274
+ it('edits the stale preview on the first chunk, consumes it, then sends the rest fresh', async () => {
275
+ const { deps, calls } = makeFake()
276
+ const state = baseState({ chunks: ['a', 'b'], previewMessageId: 900 })
277
+ const res = await sendReplyChunks(deps, state)
278
+ expect(calls[0].kind).toBe('editPreview')
279
+ expect(calls[0].messageId).toBe(900)
280
+ // preview id consumed → subsequent chunk is a fresh send
281
+ expect(calls[1].kind).toBe('sendRich')
282
+ expect(res.previewMessageId).toBeNull()
283
+ // first sentId is the reused preview id
284
+ expect(state.sentIds[0]).toBe(900)
285
+ })
286
+
287
+ it('a failed preview edit deletes the stale preview and sends fresh', async () => {
288
+ const { deps, calls, deleted } = makeFake({ failNext: { editPreview: [new Error('message to edit not found')] } })
289
+ const state = baseState({ chunks: ['a'], previewMessageId: 901 })
290
+ const res = await sendReplyChunks(deps, state)
291
+ expect(deleted).toEqual([901])
292
+ expect(calls.some((c) => c.kind === 'sendRich')).toBe(true)
293
+ expect(res.previewMessageId).toBeNull()
294
+ })
295
+
296
+ it('a "not modified" preview edit is treated as success (id reused, no fresh send)', async () => {
297
+ const { deps, calls, deleted } = makeFake({ failNext: { editPreview: [new Error('Bad Request: message is not modified')] } })
298
+ const state = baseState({ chunks: ['a'], previewMessageId: 902 })
299
+ await sendReplyChunks(deps, state)
300
+ expect(deleted).toEqual([])
301
+ expect(state.sentIds).toEqual([902])
302
+ expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(0)
303
+ })
304
+ })