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,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
+ });
@@ -261,10 +261,24 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
261
261
  )
262
262
 
263
263
  it('reply path: normalizes AFTER redact and BEFORE the voice scrub', () => {
264
- const start = gatewaySrc.indexOf('async function executeReply(')
265
- const redactIdx = gatewaySrc.indexOf(`redactOutboundText(text, 'reply')`, start)
266
- const normIdx = gatewaySrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
267
- const scrubIdx = gatewaySrc.indexOf('scrubVoice(text)', start)
264
+ // #2996: the reply-path entry pipeline moved into outbound-send-path.ts
265
+ // (`normalizeOutboundBody`). The reply path delegates to it via
266
+ // `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`; the
267
+ // redact→normalize→scrub ordering is now pinned in the module source.
268
+ const replyDelegates = gatewaySrc.indexOf(
269
+ `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
270
+ gatewaySrc.indexOf('async function executeReply('),
271
+ )
272
+ expect(replyDelegates).toBeGreaterThan(0)
273
+
274
+ const moduleSrc = readFileSync(
275
+ new URL('../gateway/outbound-send-path.ts', import.meta.url),
276
+ 'utf8',
277
+ )
278
+ const start = moduleSrc.indexOf('export function normalizeOutboundBody(')
279
+ const redactIdx = moduleSrc.indexOf('redact(text, site)', start)
280
+ const normIdx = moduleSrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
281
+ const scrubIdx = moduleSrc.indexOf('scrubVoice(text)', start)
268
282
  expect(start).toBeGreaterThan(0)
269
283
  expect(redactIdx).toBeGreaterThan(start)
270
284
  expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the reply redact
@@ -41,10 +41,14 @@ import { readFileSync } from 'node:fs'
41
41
  import { resolve } from 'node:path'
42
42
  import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
43
43
 
44
- const gatewaySrc = readFileSync(
45
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
46
- 'utf-8',
47
- )
44
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
45
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
46
+ // COMBINED with that module so the wiring assertions keep covering the
47
+ // same runtime source text.
48
+ const gatewaySrc =
49
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
50
+ '\n' +
51
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
48
52
 
49
53
  function sliceAccessApproveBlock(): string {
50
54
  const fn =
@@ -73,7 +77,10 @@ describe('vault grant approval posture — module-level wiring', () => {
73
77
  describe('handleVaultRequestAccessCallback — posture branch', () => {
74
78
  it('mints via posture attestation (NOT in-memory passphrase) when posture is telegram-id', () => {
75
79
  const approveBlock = sliceAccessApproveBlock()
76
- expect(approveBlock).toMatch(/VAULT_APPROVAL_AUTH_MODE === ['"]telegram-id['"]/)
80
+ // #2996 Phase 5: inside the extracted handler module the mutable gateway
81
+ // `let` is read via the injected getter (gateway wires it as
82
+ // `getVaultApprovalAuthMode: () => VAULT_APPROVAL_AUTH_MODE`).
83
+ expect(approveBlock).toMatch(/getVaultApprovalAuthMode\(\) === ['"]telegram-id['"]/)
77
84
  // Pinned: the call shape MUST be `{ kind: 'posture' }`. If the
78
85
  // gateway ever reverts to passing a real passphrase here, the
79
86
  // bypass surface returns.
@@ -138,7 +145,8 @@ describe('handleVaultRequestSaveCallback — posture-attested broker put (#1115
138
145
  // calls `defaultVaultWritePosture` (posture-attested broker put,
139
146
  // no passphrase). Under passphrase mode it keeps the legacy
140
147
  // cached-passphrase + shell-out path.
141
- expect(fnBlock).toMatch(/VAULT_APPROVAL_AUTH_MODE === 'telegram-id'/)
148
+ // #2996 Phase 5: getter read of the gateway's mutable posture `let`.
149
+ expect(fnBlock).toMatch(/getVaultApprovalAuthMode\(\) === 'telegram-id'/)
142
150
  expect(fnBlock).toMatch(/defaultVaultWritePosture\(/)
143
151
  // Passphrase-mode branch still present.
144
152
  expect(fnBlock).toMatch(/vaultPassphraseCache\.get\(pending\.chat_id\)/)
@@ -203,7 +211,7 @@ describe('allowlist is the first gate in every vault callback handler', () => {
203
211
  'mintGrantViaBroker',
204
212
  'performVaultAccessApproval',
205
213
  'pendingVaultOps.set',
206
- "VAULT_APPROVAL_AUTH_MODE === 'telegram-id'",
214
+ "getVaultApprovalAuthMode() === 'telegram-id'",
207
215
  'attest_via_posture',
208
216
  ]) {
209
217
  expect(
@@ -23,10 +23,14 @@ import { describe, it, expect } from "vitest";
23
23
  import { readFileSync } from "node:fs";
24
24
  import { resolve } from "node:path";
25
25
 
26
- const gatewaySrc = readFileSync(
27
- resolve(__dirname, "..", "gateway", "gateway.ts"),
28
- "utf-8",
29
- );
26
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
27
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
28
+ // COMBINED with that module so the wiring assertions keep covering the
29
+ // same runtime source text.
30
+ const gatewaySrc =
31
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
32
+ "\n" +
33
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
30
34
 
31
35
  function extractPerformBlock(): string {
32
36
  const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
@@ -22,10 +22,14 @@ import { describe, it, expect } from "vitest";
22
22
  import { readFileSync } from "node:fs";
23
23
  import { resolve } from "node:path";
24
24
 
25
- const gatewaySrc = readFileSync(
26
- resolve(__dirname, "..", "gateway", "gateway.ts"),
27
- "utf-8",
28
- );
25
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
26
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
27
+ // COMBINED with that module so the wiring assertions keep covering the
28
+ // same runtime source text.
29
+ const gatewaySrc =
30
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
31
+ "\n" +
32
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
29
33
 
30
34
  function extractPerformBlock(): string {
31
35
  const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
@@ -40,7 +40,14 @@ function readSrc(rel: string): string {
40
40
  // `telegram-plugin/docs/gateway-server-split.md` for the F4 cleanup notes.
41
41
 
42
42
  describe('/vault grant inline-keyboard wizard — gateway (#227, #262, #265)', () => {
43
- const gatewaySrc = readSrc('telegram-plugin/gateway/gateway.ts')
43
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
44
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
45
+ // COMBINED with that module so the wiring assertions keep covering the
46
+ // same runtime source text.
47
+ const gatewaySrc =
48
+ readSrc('telegram-plugin/gateway/gateway.ts') +
49
+ '\n' +
50
+ readSrc('telegram-plugin/gateway/callback-query-handlers.ts')
44
51
 
45
52
  it('gateway.ts: dispatches /vault grant to the wizard entry', () => {
46
53
  expect(gatewaySrc).toMatch(/\/vault grant/i)
@@ -33,7 +33,14 @@ import { join, dirname } from 'node:path'
33
33
  const __dir = dirname(fileURLToPath(import.meta.url))
34
34
  const pluginDir = join(__dir, '..')
35
35
 
36
- const gatewaySrc = readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8')
36
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
37
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
38
+ // COMBINED with that module so the wiring assertions keep covering the
39
+ // same runtime source text.
40
+ const gatewaySrc =
41
+ readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8') +
42
+ '\n' +
43
+ readFileSync(join(pluginDir, 'gateway', 'callback-query-handlers.ts'), 'utf8')
37
44
 
38
45
  // ─── helpers ─────────────────────────────────────────────────────────────────
39
46
 
@@ -27,10 +27,14 @@ import { describe, it, expect } from "vitest";
27
27
  import { readFileSync } from "node:fs";
28
28
  import { resolve } from "node:path";
29
29
 
30
- const gatewaySrc = readFileSync(
31
- resolve(__dirname, "..", "gateway", "gateway.ts"),
32
- "utf-8",
33
- );
30
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
31
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
32
+ // COMBINED with that module so the wiring assertions keep covering the
33
+ // same runtime source text.
34
+ const gatewaySrc =
35
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
36
+ "\n" +
37
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
34
38
 
35
39
  /** The exported regex literal — what the gateway actually validates against. */
36
40
  function extractVaultKeyRegex(): RegExp {
@@ -23,10 +23,14 @@ const bridgeSrc = readFileSync(
23
23
  resolve(__dirname, '..', 'bridge', 'bridge.ts'),
24
24
  'utf-8',
25
25
  )
26
- const gatewaySrc = readFileSync(
27
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
28
- 'utf-8',
29
- )
26
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
27
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
28
+ // COMBINED with that module so the wiring assertions keep covering the
29
+ // same runtime source text.
30
+ const gatewaySrc =
31
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
32
+ '\n' +
33
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
30
34
 
31
35
  describe('vault_request_access (#1012)', () => {
32
36
  it('bridge advertises the tool to MCP clients', () => {
@@ -22,10 +22,14 @@ import { describe, it, expect } from 'vitest'
22
22
  import { readFileSync } from 'node:fs'
23
23
  import { resolve } from 'node:path'
24
24
 
25
- const gatewaySrc = readFileSync(
26
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
27
- 'utf-8',
28
- )
25
+ // #2996 Phase 5: the callback-query handler families moved verbatim to
26
+ // gateway/callback-query-handlers.ts; these pins read the gateway source
27
+ // COMBINED with that module so the wiring assertions keep covering the
28
+ // same runtime source text.
29
+ const gatewaySrc =
30
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
31
+ '\n' +
32
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
29
33
 
30
34
  describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
31
35
  it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Telegram file_id reuse for the on-demand '🔊 Listen' send path.
3
+ *
4
+ * gateway.ts is a 25k-line module with import-time side effects, so the send
5
+ * decision core lives in ../voice-send.ts (imported by the gateway). These
6
+ * tests drive the real orchestrator + cache primitive with a mocked bot api,
7
+ * proving the observable contract the gateway wires up:
8
+ *
9
+ * (a) FIRST tap (no stored id) uploads via InputFile and captures the
10
+ * returned file_id onto the cache entry;
11
+ * (b) SECOND tap sends BY the file_id string — no disk read, no InputFile;
12
+ * (c) a file_id send REJECTED as stale (400) falls back to a re-upload and
13
+ * refreshes the stored id, so a dead id never permanently breaks Listen.
14
+ */
15
+
16
+ import { describe, it, expect } from 'bun:test'
17
+ import {
18
+ sendVoiceReusingFileId,
19
+ extractVoiceFileId,
20
+ isInvalidTelegramFileIdError,
21
+ isHttp400Error,
22
+ type SentVoiceMessage,
23
+ } from '../voice-send.js'
24
+ import { VoiceOnDemandCache } from '../voice-ondemand.js'
25
+
26
+ /** A minimal grammy-shaped 400 error (duck-typed the way the gateway sees it). */
27
+ function grammy400(description: string): Error {
28
+ return Object.assign(new Error(description), { error_code: 400, description })
29
+ }
30
+
31
+ /** Sent-message stub echoing a file_id, as Telegram returns from sendVoice. */
32
+ function sentWithFileId(fileId: string): SentVoiceMessage {
33
+ return { voice: { file_id: fileId } }
34
+ }
35
+
36
+ const OGG = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
37
+
38
+ describe('sendVoiceReusingFileId — file_id reuse contract', () => {
39
+ it('(a) first tap: no stored id → uploads via InputFile, captures the id', async () => {
40
+ const calls: string[] = []
41
+ let byFileId = 0
42
+ let uploaded = 0
43
+ let captured: string | null = null
44
+
45
+ const res = await sendVoiceReusingFileId({
46
+ fileId: null,
47
+ sendByFileId: async () => {
48
+ byFileId++
49
+ return null
50
+ },
51
+ loadAudio: async () => {
52
+ calls.push('loadAudio')
53
+ return OGG
54
+ },
55
+ sendByUpload: async (audio) => {
56
+ uploaded++
57
+ expect(audio).toBe(OGG)
58
+ return sentWithFileId('FILE_ID_1')
59
+ },
60
+ onFileId: (id) => {
61
+ captured = id
62
+ },
63
+ })
64
+
65
+ expect(res).toEqual({ ok: true, path: 'upload', refreshed: false })
66
+ expect(byFileId).toBe(0) // never tried the id path — none stored
67
+ expect(uploaded).toBe(1) // exactly one upload
68
+ expect(calls).toEqual(['loadAudio']) // audio WAS loaded on the first tap
69
+ expect(captured).toBe('FILE_ID_1') // id captured for reuse
70
+ })
71
+
72
+ it('(b) second tap: stored id → sends by file_id string, no disk read, no InputFile', async () => {
73
+ let loadAudioCalls = 0
74
+ let uploadCalls = 0
75
+ let sentId: string | null = null
76
+
77
+ const res = await sendVoiceReusingFileId({
78
+ fileId: 'FILE_ID_1',
79
+ sendByFileId: async (fid) => {
80
+ sentId = fid
81
+ return sentWithFileId('FILE_ID_1') // Telegram echoes the same id
82
+ },
83
+ loadAudio: async () => {
84
+ loadAudioCalls++ // MUST NOT happen — proves no disk read
85
+ return OGG
86
+ },
87
+ sendByUpload: async () => {
88
+ uploadCalls++ // MUST NOT happen — proves no re-upload
89
+ return null
90
+ },
91
+ onFileId: () => {},
92
+ })
93
+
94
+ expect(res).toEqual({ ok: true, path: 'file_id', refreshed: false })
95
+ expect(sentId).toBe('FILE_ID_1') // sent BY the string id
96
+ expect(loadAudioCalls).toBe(0) // no disk read
97
+ expect(uploadCalls).toBe(0) // no InputFile upload
98
+ })
99
+
100
+ it('(c) stale id (400): falls back to re-upload and refreshes the stored id', async () => {
101
+ let byFileId = 0
102
+ let uploaded = 0
103
+ let refreshedTo: string | null = null
104
+
105
+ const res = await sendVoiceReusingFileId({
106
+ fileId: 'STALE_ID',
107
+ sendByFileId: async () => {
108
+ byFileId++
109
+ throw grammy400('Bad Request: wrong file identifier/HTTP URL specified')
110
+ },
111
+ loadAudio: async () => OGG,
112
+ sendByUpload: async () => {
113
+ uploaded++
114
+ return sentWithFileId('FRESH_ID')
115
+ },
116
+ onFileId: (id) => {
117
+ refreshedTo = id
118
+ },
119
+ })
120
+
121
+ expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
122
+ expect(byFileId).toBe(1) // tried the stale id once
123
+ expect(uploaded).toBe(1) // then re-uploaded
124
+ expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
125
+ })
126
+
127
+ it('a NON-stale send error is a real failure — no silent re-upload', async () => {
128
+ let uploaded = 0
129
+ const res = await sendVoiceReusingFileId({
130
+ fileId: 'FILE_ID_1',
131
+ sendByFileId: async () => {
132
+ throw Object.assign(new Error('forbidden'), { error_code: 403 })
133
+ },
134
+ loadAudio: async () => OGG,
135
+ sendByUpload: async () => {
136
+ uploaded++
137
+ return null
138
+ },
139
+ onFileId: () => {},
140
+ })
141
+ expect(res.ok).toBe(false)
142
+ expect(uploaded).toBe(0) // did NOT paper over a 403 with an upload
143
+ })
144
+
145
+ it('(C1) UNKNOWN-wording 400: re-uploads + refreshes id even outside the stale-id substrings', async () => {
146
+ // A 400 whose text is NOT in isInvalidTelegramFileIdError's substring set.
147
+ // Pre-tweak (substring-gated) this classified as send-failed and never
148
+ // refreshed the id; post-tweak ANY 400 recovers via re-upload.
149
+ let byFileId = 0
150
+ let uploaded = 0
151
+ let refreshedTo: string | null = null
152
+
153
+ const err400 = grammy400('Bad Request: some brand-new file rejection wording')
154
+ expect(isInvalidTelegramFileIdError(err400)).toBe(false) // guard: outside old substrings
155
+ expect(isHttp400Error(err400)).toBe(true) // but IS a 400 → now re-uploadable
156
+
157
+ const res = await sendVoiceReusingFileId({
158
+ fileId: 'STALE_ID',
159
+ sendByFileId: async () => {
160
+ byFileId++
161
+ throw err400
162
+ },
163
+ loadAudio: async () => OGG,
164
+ sendByUpload: async () => {
165
+ uploaded++
166
+ return sentWithFileId('FRESH_ID')
167
+ },
168
+ onFileId: (id) => {
169
+ refreshedTo = id
170
+ },
171
+ })
172
+
173
+ expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
174
+ expect(byFileId).toBe(1) // tried the id once
175
+ expect(uploaded).toBe(1) // then re-uploaded on the unknown 400
176
+ expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
177
+ })
178
+
179
+ it('(C1) a network error (no error_code) still does NOT re-upload', async () => {
180
+ let uploaded = 0
181
+ const res = await sendVoiceReusingFileId({
182
+ fileId: 'FILE_ID_1',
183
+ sendByFileId: async () => {
184
+ throw new Error('ECONNRESET') // no error_code → not a 400
185
+ },
186
+ loadAudio: async () => OGG,
187
+ sendByUpload: async () => {
188
+ uploaded++
189
+ return null
190
+ },
191
+ onFileId: () => {},
192
+ })
193
+ expect(res.ok).toBe(false)
194
+ expect(uploaded).toBe(0) // transient network error is a real failure, not a re-upload
195
+ })
196
+
197
+ it('(C1) re-upload after a 400 ALSO fails → returns failure, no second re-upload', async () => {
198
+ let byFileId = 0
199
+ let uploaded = 0
200
+ let refreshed: string | null = null
201
+
202
+ const res = await sendVoiceReusingFileId({
203
+ fileId: 'STALE_ID',
204
+ sendByFileId: async () => {
205
+ byFileId++
206
+ throw grammy400('Bad Request: totally unfamiliar 400 wording')
207
+ },
208
+ loadAudio: async () => OGG,
209
+ sendByUpload: async () => {
210
+ uploaded++
211
+ throw grammy400('Bad Request: upload also rejected') // recovery send fails too
212
+ },
213
+ onFileId: (id) => {
214
+ refreshed = id
215
+ },
216
+ })
217
+
218
+ expect(res.ok).toBe(false)
219
+ if (!res.ok) expect(res.reason).toBe('send-failed')
220
+ expect(byFileId).toBe(1) // id tried once
221
+ expect(uploaded).toBe(1) // re-uploaded exactly once — NO second attempt / loop
222
+ expect(refreshed).toBeNull() // failed upload never refreshed the id
223
+ })
224
+
225
+ it('no-audio on the upload path returns { ok:false, reason:"no-audio" }', async () => {
226
+ const res = await sendVoiceReusingFileId({
227
+ fileId: null,
228
+ sendByFileId: async () => null,
229
+ loadAudio: async () => null, // swept + synth unavailable
230
+ sendByUpload: async () => {
231
+ throw new Error('should not be called')
232
+ },
233
+ onFileId: () => {},
234
+ })
235
+ expect(res).toEqual({ ok: false, reason: 'no-audio' })
236
+ })
237
+ })
238
+
239
+ describe('extractVoiceFileId / isInvalidTelegramFileIdError', () => {
240
+ it('extracts a present file_id, ignores missing/empty', () => {
241
+ expect(extractVoiceFileId({ voice: { file_id: 'abc' } })).toBe('abc')
242
+ expect(extractVoiceFileId({ voice: {} })).toBeUndefined()
243
+ expect(extractVoiceFileId({ voice: { file_id: '' } })).toBeUndefined()
244
+ expect(extractVoiceFileId(null)).toBeUndefined()
245
+ expect(extractVoiceFileId(undefined)).toBeUndefined()
246
+ })
247
+
248
+ it('classifies a 400 wrong-file-identifier as stale, others as not', () => {
249
+ expect(
250
+ isInvalidTelegramFileIdError(grammy400('Bad Request: wrong file identifier/HTTP URL specified')),
251
+ ).toBe(true)
252
+ expect(isInvalidTelegramFileIdError(grammy400('Bad Request: wrong remote file identifier'))).toBe(
253
+ true,
254
+ )
255
+ // Not a file_id problem — genuine failures the caller must surface.
256
+ expect(isInvalidTelegramFileIdError(grammy400('Bad Request: chat not found'))).toBe(false)
257
+ expect(
258
+ isInvalidTelegramFileIdError(Object.assign(new Error('forbidden'), { error_code: 403 })),
259
+ ).toBe(false)
260
+ expect(isInvalidTelegramFileIdError(new Error('network'))).toBe(false)
261
+ expect(isInvalidTelegramFileIdError(null)).toBe(false)
262
+ })
263
+
264
+ it('isHttp400Error: true only for error_code 400, wording-agnostic', () => {
265
+ expect(isHttp400Error(grammy400('any wording at all'))).toBe(true)
266
+ expect(isHttp400Error(grammy400('Bad Request: chat not found'))).toBe(true) // 400 regardless of text
267
+ expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 403 }))).toBe(false)
268
+ expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 500 }))).toBe(false)
269
+ expect(isHttp400Error(new Error('network'))).toBe(false) // no error_code
270
+ expect(isHttp400Error(null)).toBe(false)
271
+ expect(isHttp400Error(undefined)).toBe(false)
272
+ })
273
+ })
274
+
275
+ describe('VoiceOnDemandCache.setTelegramFileId — capture + sweep drop', () => {
276
+ it('stores an id on a live entry and returns it via get()', () => {
277
+ const cache = new VoiceOnDemandCache()
278
+ cache.put('tok', { text: 'hi', speed: 1.1 })
279
+ cache.setTelegramFileId('tok', 'FILE_ID_1')
280
+ expect(cache.get('tok')?.telegramFileId).toBe('FILE_ID_1')
281
+ })
282
+
283
+ it('ignores an empty id and unknown/evicted tokens', () => {
284
+ const cache = new VoiceOnDemandCache()
285
+ cache.put('tok', { text: 'hi', speed: 1.1 })
286
+ cache.setTelegramFileId('tok', '') // ignored
287
+ expect(cache.get('tok')?.telegramFileId).toBeUndefined()
288
+ cache.setTelegramFileId('missing', 'X') // no-op, no throw
289
+ expect(cache.get('missing')).toBeNull()
290
+ })
291
+
292
+ it('drops the stored id when the 7-day sweep prunes the entry — no resurrection', () => {
293
+ const cache = new VoiceOnDemandCache()
294
+ cache.put('tok', { text: 'hi', speed: 1.1 })
295
+ cache.setTelegramFileId('tok', 'FILE_ID_1')
296
+ cache.prune(['tok']) // sweep deleted the on-disk file for this token
297
+ expect(cache.get('tok')).toBeNull() // entry AND its file_id are gone
298
+ })
299
+
300
+ it('does not store an id on an already-expired entry', () => {
301
+ let t = 1000
302
+ const cache = new VoiceOnDemandCache({ ttlMs: 100, now: () => t })
303
+ cache.put('tok', { text: 'hi', speed: 1.1 })
304
+ t = 2000 // past TTL
305
+ cache.setTelegramFileId('tok', 'FILE_ID_1') // no-op on expired entry
306
+ expect(cache.get('tok')).toBeNull()
307
+ })
308
+ })