switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Outcome pins for the ask_user option-callback family (`aq:<idx>:<askId>`,
3
+ * #574) extracted from the gateway's callback_query dispatcher
4
+ * (switchroom#2996 P6). Uses the REAL encode/decode from ask-user.ts and
5
+ * asserts: non-ask data falls through (returns false), the allowFrom gate,
6
+ * stale-tap and out-of-bounds acks, and the full resolve path (timer cleared,
7
+ * store entry deleted, promise resolved with the choice, message edited with
8
+ * the checkmark, toast acked).
9
+ */
10
+
11
+ import { describe, it, expect, vi } from 'vitest'
12
+ import {
13
+ handleAskCallback,
14
+ type AskCallbackDeps,
15
+ type PendingAskUserEntry,
16
+ } from '../gateway/ask-callback-handler.js'
17
+ import { encodeAskCallback } from '../ask-user.js'
18
+
19
+ function makeCtx(over: Record<string, unknown> = {}) {
20
+ return {
21
+ from: { id: 42 },
22
+ callbackQuery: { message: { text: 'Pick one <a&b>' } },
23
+ answerCallbackQuery: vi.fn(async () => {}),
24
+ editMessageText: vi.fn(async () => {}),
25
+ ...over,
26
+ } as any
27
+ }
28
+
29
+ function makeDeps(entries: Record<string, PendingAskUserEntry> = {}) {
30
+ const store = new Map(Object.entries(entries))
31
+ const deps: AskCallbackDeps = {
32
+ loadAccess: () => ({ allowFrom: ['42'] }),
33
+ pendingAskUser: store,
34
+ }
35
+ return { deps, store }
36
+ }
37
+
38
+ function entryWith(options: string[]) {
39
+ let resolved: unknown = null
40
+ const entry: PendingAskUserEntry = {
41
+ options,
42
+ resolve: (o: unknown) => {
43
+ resolved = o
44
+ },
45
+ timer: setTimeout(() => {}, 60_000),
46
+ }
47
+ return { entry, getResolved: () => resolved }
48
+ }
49
+
50
+ describe('handleAskCallback — routing', () => {
51
+ it('returns false for non-ask callback data (dispatcher falls through)', async () => {
52
+ const { deps } = makeDeps()
53
+ const ctx = makeCtx()
54
+ expect(await handleAskCallback(ctx, 'apv:whatever', deps)).toBe(false)
55
+ expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
56
+ })
57
+
58
+ it('gates non-allowlisted senders with a Not authorized toast', async () => {
59
+ const { entry } = entryWith(['a'])
60
+ const { deps } = makeDeps({ aaaa1111: entry })
61
+ const ctx = makeCtx({ from: { id: 666 } })
62
+ expect(await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 0), deps)).toBe(true)
63
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
64
+ clearTimeout(entry.timer)
65
+ })
66
+
67
+ it('acks a stale tap (entry gone) with the expired toast', async () => {
68
+ const { deps } = makeDeps()
69
+ const ctx = makeCtx()
70
+ expect(await handleAskCallback(ctx, encodeAskCallback('deadbeef', 0), deps)).toBe(true)
71
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'This question expired.' })
72
+ })
73
+
74
+ it('rejects an out-of-bounds index (hostile callback) without resolving', async () => {
75
+ const { entry, getResolved } = entryWith(['only'])
76
+ const { deps, store } = makeDeps({ aaaa1111: entry })
77
+ const ctx = makeCtx()
78
+ await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 3), deps)
79
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Invalid option.' })
80
+ expect(getResolved()).toBeNull()
81
+ expect(store.has('aaaa1111')).toBe(true)
82
+ clearTimeout(entry.timer)
83
+ })
84
+ })
85
+
86
+ describe('handleAskCallback — resolve path', () => {
87
+ it('resolves the deferred, deletes the entry, edits the message, acks the toast', async () => {
88
+ const { entry, getResolved } = entryWith(['yes', 'no'])
89
+ const { deps, store } = makeDeps({ aaaa1111: entry })
90
+ const ctx = makeCtx()
91
+ expect(await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 1), deps)).toBe(true)
92
+ expect(getResolved()).toEqual({ kind: 'answered', choice: 'no', idx: 1 })
93
+ expect(store.has('aaaa1111')).toBe(false)
94
+ // Edited body: escaped source text + checkmarked choice.
95
+ const edited = JSON.stringify(ctx.editMessageText.mock.calls[0][0])
96
+ expect(edited).toContain('✅')
97
+ expect(edited).toContain('no')
98
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✅ no' })
99
+ })
100
+
101
+ it('still resolves + acks when the source message has no text (no edit attempted)', async () => {
102
+ const { entry, getResolved } = entryWith(['ok'])
103
+ const { deps } = makeDeps({ aaaa1111: entry })
104
+ const ctx = makeCtx({ callbackQuery: { message: {} } })
105
+ await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 0), deps)
106
+ expect(getResolved()).toEqual({ kind: 'answered', choice: 'ok', idx: 0 })
107
+ expect(ctx.editMessageText).not.toHaveBeenCalled()
108
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✅ ok' })
109
+ })
110
+
111
+ it('a failing editMessageText is swallowed — the ack toast still fires', async () => {
112
+ const { entry } = entryWith(['ok'])
113
+ const { deps } = makeDeps({ aaaa1111: entry })
114
+ const ctx = makeCtx({ editMessageText: vi.fn(async () => { throw new Error('too old') }) })
115
+ await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 0), deps)
116
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✅ ok' })
117
+ })
118
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Outcome pins for the metadata-only attachment inbound handlers extracted
3
+ * from gateway.ts (switchroom#2996 P6 cluster C). Each handler is driven with
4
+ * a fake grammy ctx + a mock coalescing dispatch dep; the assertions pin the
5
+ * EXACT text envelope and AttachmentMeta slot forwarded to
6
+ * handleInboundCoalesced — the behaviour the gateway previously produced inline.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import {
11
+ handleDocumentMessage,
12
+ handleAudioMessage,
13
+ handleVideoMessage,
14
+ handleVideoNoteMessage,
15
+ handleStickerMessage,
16
+ handleAnimationMessage,
17
+ type AttachmentHandlerDeps,
18
+ } from '../gateway/attachment-message-handlers.js'
19
+
20
+ function makeDeps() {
21
+ const handleInboundCoalesced = vi.fn(async () => {})
22
+ const deps: AttachmentHandlerDeps = { handleInboundCoalesced }
23
+ return { deps, handleInboundCoalesced }
24
+ }
25
+
26
+ // Build a fake ctx whose `message` carries the given content key (+ caption).
27
+ function ctxWith(content: Record<string, unknown>): any {
28
+ return { message: content, chat: { id: 42 } }
29
+ }
30
+
31
+ describe('handleDocumentMessage', () => {
32
+ it('renders the document envelope with a sanitized name', async () => {
33
+ const { deps, handleInboundCoalesced } = makeDeps()
34
+ const ctx = ctxWith({ document: { file_id: 'd1', file_name: 'a<b>.pdf', file_size: 10, mime_type: 'application/pdf' } })
35
+ await handleDocumentMessage(ctx, deps)
36
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
37
+ ctx,
38
+ '(document: a_b_.pdf)',
39
+ undefined,
40
+ { kind: 'document', file_id: 'd1', size: 10, mime: 'application/pdf', name: 'a_b_.pdf' },
41
+ )
42
+ })
43
+ it('prefers the caption and falls back to "file" when unnamed', async () => {
44
+ const { deps, handleInboundCoalesced } = makeDeps()
45
+ const ctx = ctxWith({ document: { file_id: 'd2' }, caption: 'see this' })
46
+ await handleDocumentMessage(ctx, deps)
47
+ const [, text, , meta] = (handleInboundCoalesced as any).mock.calls[0]
48
+ expect(text).toBe('see this')
49
+ expect(meta).toEqual({ kind: 'document', file_id: 'd2', size: undefined, mime: undefined, name: undefined })
50
+ })
51
+ })
52
+
53
+ describe('handleAudioMessage', () => {
54
+ it('renders title, then name, then "audio" fallback', async () => {
55
+ const { deps, handleInboundCoalesced } = makeDeps()
56
+ const ctx = ctxWith({ audio: { file_id: 'a1', title: 'Song', file_name: 'x.mp3', file_size: 5, mime_type: 'audio/mpeg' } })
57
+ await handleAudioMessage(ctx, deps)
58
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
59
+ ctx,
60
+ '(audio: Song)',
61
+ undefined,
62
+ { kind: 'audio', file_id: 'a1', size: 5, mime: 'audio/mpeg', name: 'x.mp3' },
63
+ )
64
+ })
65
+ })
66
+
67
+ describe('handleVideoMessage', () => {
68
+ it('renders the video envelope, caption-or-fallback', async () => {
69
+ const { deps, handleInboundCoalesced } = makeDeps()
70
+ const ctx = ctxWith({ video: { file_id: 'v1', file_size: 9, mime_type: 'video/mp4', file_name: 'clip.mp4' } })
71
+ await handleVideoMessage(ctx, deps)
72
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
73
+ ctx,
74
+ '(video)',
75
+ undefined,
76
+ { kind: 'video', file_id: 'v1', size: 9, mime: 'video/mp4', name: 'clip.mp4' },
77
+ )
78
+ })
79
+ })
80
+
81
+ describe('handleVideoNoteMessage', () => {
82
+ it('renders the fixed video-note envelope', async () => {
83
+ const { deps, handleInboundCoalesced } = makeDeps()
84
+ const ctx = ctxWith({ video_note: { file_id: 'vn1', file_size: 3 } })
85
+ await handleVideoNoteMessage(ctx, deps)
86
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
87
+ ctx,
88
+ '(video note)',
89
+ undefined,
90
+ { kind: 'video_note', file_id: 'vn1', size: 3 },
91
+ )
92
+ })
93
+ })
94
+
95
+ describe('handleStickerMessage', () => {
96
+ it('includes emoji and set name when present', async () => {
97
+ const { deps, handleInboundCoalesced } = makeDeps()
98
+ const ctx = ctxWith({ sticker: { file_id: 's1', emoji: '🎉', set_name: 'Party', file_size: 2 } })
99
+ await handleStickerMessage(ctx, deps)
100
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
101
+ ctx,
102
+ '(sticker — 🎉 from "Party")',
103
+ undefined,
104
+ { kind: 'sticker', file_id: 's1', size: 2 },
105
+ )
106
+ })
107
+ it('falls back to bare "(sticker)" with no emoji/set', async () => {
108
+ const { deps, handleInboundCoalesced } = makeDeps()
109
+ const ctx = ctxWith({ sticker: { file_id: 's2' } })
110
+ await handleStickerMessage(ctx, deps)
111
+ const [, text] = (handleInboundCoalesced as any).mock.calls[0]
112
+ expect(text).toBe('(sticker)')
113
+ })
114
+ })
115
+
116
+ describe('handleAnimationMessage', () => {
117
+ it('prefixes the caption to the gif envelope', async () => {
118
+ const { deps, handleInboundCoalesced } = makeDeps()
119
+ const ctx = ctxWith({ animation: { file_id: 'g1', file_size: 7, mime_type: 'video/mp4', file_name: 'a.gif' }, caption: 'perfect' })
120
+ await handleAnimationMessage(ctx, deps)
121
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
122
+ ctx,
123
+ '(gif) perfect',
124
+ undefined,
125
+ { kind: 'animation', file_id: 'g1', size: 7, mime: 'video/mp4', name: 'a.gif' },
126
+ )
127
+ })
128
+ it('renders bare "(gif)" when uncaptioned', async () => {
129
+ const { deps, handleInboundCoalesced } = makeDeps()
130
+ const ctx = ctxWith({ animation: { file_id: 'g2' } })
131
+ await handleAnimationMessage(ctx, deps)
132
+ const [, text] = (handleInboundCoalesced as any).mock.calls[0]
133
+ expect(text).toBe('(gif)')
134
+ })
135
+ })
@@ -5,6 +5,8 @@ import {
5
5
  backstopReceiptIds,
6
6
  backstopDelivered,
7
7
  runBackstopDelivery,
8
+ combineReadBackResults,
9
+ type ReadBackResult,
8
10
  } from '../gateway/backstop-delivery.js'
9
11
  import {
10
12
  backstopSendOutcomeGated,
@@ -248,3 +250,168 @@ describe('runBackstopDelivery — integration oracle over the delivery wiring',
248
250
  expect(recorded[0].ids).toHaveLength(recorded[0].texts.length)
249
251
  })
250
252
  })
253
+
254
+ /**
255
+ * #3278 — read-back confirmation of an accepted-but-dropped send.
256
+ *
257
+ * A returned message_id proves Telegram ACCEPTED the send, not that the message
258
+ * is VISIBLE (a flood/anti-spam silent discard returns a fresh id yet the user
259
+ * sees nothing). These assert the deterministic OUTCOMES of the per-chunk state
260
+ * machine `unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`
261
+ * driven by a no-op editMessageText read-back — NOT code-path execution.
262
+ */
263
+ describe('#3278 read-back — the ledger confirmation state machine', () => {
264
+ it('confirmChunk transitions landed-unconfirmed → landed-confirmed', () => {
265
+ const l = new BackstopDeliveryLedger()
266
+ l.recordChunk('#t', 0, [900]) // landed-unconfirmed
267
+ expect(l.hasChunk('#t', 0)).toBe(true)
268
+ expect(l.hasConfirmedChunk('#t', 0)).toBe(false)
269
+ expect(l.landedUnconfirmedIndices('#t', 1)).toEqual([0])
270
+ l.confirmChunk('#t', 0)
271
+ expect(l.hasConfirmedChunk('#t', 0)).toBe(true)
272
+ expect(l.landedUnconfirmedIndices('#t', 1)).toEqual([])
273
+ expect(l.allConfirmed('#t', 1)).toBe(true)
274
+ expect(l.confirmedIds('#t')).toEqual([900])
275
+ })
276
+
277
+ it('demoteChunk resets a landed(-unconfirmed) chunk back to unsent (safe re-send)', () => {
278
+ const l = new BackstopDeliveryLedger()
279
+ l.recordChunk('#t', 0, [901])
280
+ l.confirmChunk('#t', 0)
281
+ l.demoteChunk('#t', 0) // positive-absence read-back
282
+ expect(l.hasChunk('#t', 0)).toBe(false)
283
+ expect(l.hasConfirmedChunk('#t', 0)).toBe(false)
284
+ expect(l.unsentIndices('#t', 1)).toEqual([0]) // resume set includes it again
285
+ expect(l.confirmedIds('#t')).toEqual([])
286
+ })
287
+
288
+ it('allConfirmed is false while any chunk is only landed-unconfirmed', () => {
289
+ const l = new BackstopDeliveryLedger()
290
+ l.recordChunk('#t', 0, [1]); l.confirmChunk('#t', 0)
291
+ l.recordChunk('#t', 1, [2]) // landed-unconfirmed
292
+ expect(l.allConfirmed('#t', 2)).toBe(false)
293
+ expect(l.landedUnconfirmedIndices('#t', 2)).toEqual([1])
294
+ })
295
+
296
+ it('confirmedIds returns ONLY confirmed chunk ids, in index order', () => {
297
+ const l = new BackstopDeliveryLedger()
298
+ l.recordChunk('#t', 0, [10]); l.confirmChunk('#t', 0)
299
+ l.recordChunk('#t', 1, [11]) // unconfirmed
300
+ l.recordChunk('#t', 2, [12, 13]); l.confirmChunk('#t', 2)
301
+ expect(l.confirmedIds('#t')).toEqual([10, 12, 13])
302
+ })
303
+ })
304
+
305
+ describe('#3278 combineReadBackResults — per-chunk id combine (guard A7)', () => {
306
+ it('ANY absent id ⇒ absent (re-send the whole chunk)', () => {
307
+ expect(combineReadBackResults(['exists', 'absent'])).toBe('absent')
308
+ expect(combineReadBackResults(['absent', 'ambiguous'])).toBe('absent')
309
+ })
310
+ it('ALL exist ⇒ exists', () => {
311
+ expect(combineReadBackResults(['exists', 'exists'])).toBe('exists')
312
+ })
313
+ it('some ambiguous, none absent ⇒ ambiguous (never re-send)', () => {
314
+ expect(combineReadBackResults(['exists', 'ambiguous'])).toBe('ambiguous')
315
+ })
316
+ it('empty id set ⇒ ambiguous (fabricate neither confirm nor demote)', () => {
317
+ expect(combineReadBackResults([])).toBe('ambiguous')
318
+ })
319
+ })
320
+
321
+ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome', () => {
322
+ it('exists probe ⇒ landed-confirmed, delivered=true, ONE probe, no re-send', async () => {
323
+ const ledger = new BackstopDeliveryLedger()
324
+ const sendChunk = vi.fn(async () => [960])
325
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'exists')
326
+ const res = await runBackstopDelivery(ledger, '#ok', ['the answer'], null, { sendChunk, readBack }, 3)
327
+ expect(res.delivered).toBe(true)
328
+ expect(res.exhausted).toBe(false)
329
+ expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent
330
+ expect(readBack).toHaveBeenCalledTimes(1) // exactly one probe per chunk
331
+ expect(ledger.hasConfirmedChunk('#ok', 0)).toBe(true)
332
+ })
333
+
334
+ it('400/absent probe ⇒ demote → RE-SENT EXACTLY ONCE, then confirmed', async () => {
335
+ const ledger = new BackstopDeliveryLedger()
336
+ let sends = 0
337
+ const sendChunk = vi.fn(async () => { sends++; return [900 + sends] })
338
+ let probes = 0
339
+ // First probe: the send was silently dropped (absent). After the re-send the
340
+ // message is present (exists).
341
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => {
342
+ probes++
343
+ return probes === 1 ? 'absent' : 'exists'
344
+ })
345
+ const res = await runBackstopDelivery(ledger, '#drop', ['answer'], null, { sendChunk, readBack }, 3)
346
+ expect(sendChunk).toHaveBeenCalledTimes(2) // initial + exactly one re-send
347
+ expect(res.delivered).toBe(true)
348
+ expect(ledger.hasConfirmedChunk('#drop', 0)).toBe(true)
349
+ })
350
+
351
+ it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, delivered=false', async () => {
352
+ const ledger = new BackstopDeliveryLedger()
353
+ const sendChunk = vi.fn(async () => [950])
354
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
355
+ const res = await runBackstopDelivery(ledger, '#amb', ['answer'], null, { sendChunk, readBack }, 3)
356
+ expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent on ambiguous
357
+ expect(res.delivered).toBe(false)
358
+ expect(res.exhausted).toBe(true)
359
+ expect(ledger.hasConfirmedChunk('#amb', 0)).toBe(false)
360
+ expect(ledger.landedUnconfirmedIndices('#amb', 1)).toEqual([0]) // still landed-unconfirmed
361
+ })
362
+
363
+ it('a read-back adapter THAT THROWS is treated as ambiguous — never re-sent', async () => {
364
+ const ledger = new BackstopDeliveryLedger()
365
+ const sendChunk = vi.fn(async () => [951])
366
+ const readBack = vi.fn(async () => { throw new Error('boom') })
367
+ const res = await runBackstopDelivery(ledger, '#throw', ['answer'], null, { sendChunk, readBack }, 3)
368
+ expect(sendChunk).toHaveBeenCalledTimes(1)
369
+ expect(res.delivered).toBe(false)
370
+ })
371
+
372
+ it('#3278 CORE: API-ack fresh id but read-back ABSENT ⇒ send_failed, NOT complete', async () => {
373
+ const ledger = new BackstopDeliveryLedger()
374
+ const cardId = 500
375
+ // The Bot API returns a fresh non-card id (an accept) every time...
376
+ const sendChunk = vi.fn(async () => [970])
377
+ // ...but the read-back proves the message is not actually in the chat
378
+ // (flood/anti-spam silent discard). It never confirms.
379
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'absent')
380
+ const res = await runBackstopDelivery(ledger, '#ghost', ['answer'], cardId, { sendChunk, readBack }, 3)
381
+ expect(res.delivered).toBe(false) // pre-#3278 this was true → false `complete`
382
+ expect(res.exhausted).toBe(true)
383
+ // The exact input the gateway feeds the turn record: delivered=false ⇒ the
384
+ // status is send_failed and the obligation is left OPEN — the answer is NOT
385
+ // silently marked complete.
386
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
387
+ finalAnswerDelivered: true,
388
+ }
389
+ finalizeBackstopSendGated(turn, {
390
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
391
+ })
392
+ expect(computeTurnStatus(turn)).toBe('send_failed')
393
+ })
394
+
395
+ it('scope guard: NO readBack dep ⇒ ZERO probes, legacy confirm-on-landing preserved', async () => {
396
+ const ledger = new BackstopDeliveryLedger()
397
+ const readBack = vi.fn()
398
+ const sendChunk = vi.fn(async () => [980])
399
+ // deliberately omit `readBack` — the hot path / opt-out shape.
400
+ const res = await runBackstopDelivery(ledger, '#noprobe', ['answer'], null, { sendChunk })
401
+ expect(readBack).not.toHaveBeenCalled()
402
+ expect(res.delivered).toBe(true) // confirmed on landing (receipt-gated)
403
+ expect(ledger.hasConfirmedChunk('#noprobe', 0)).toBe(true)
404
+ })
405
+
406
+ it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ delivered=false, chunk 0 not re-sent', async () => {
407
+ const ledger = new BackstopDeliveryLedger()
408
+ const calls: number[] = []
409
+ const sendChunk = vi.fn(async (i: number) => { calls.push(i); return [700 + i] })
410
+ const readBack = vi.fn(async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'))
411
+ const res = await runBackstopDelivery(ledger, '#part', ['c0', 'c1'], null, { sendChunk, readBack }, 3)
412
+ expect(res.delivered).toBe(false)
413
+ expect(calls.filter(i => i === 0)).toHaveLength(1) // confirmed chunk never re-sent
414
+ expect(ledger.hasConfirmedChunk('#part', 0)).toBe(true)
415
+ expect(ledger.hasConfirmedChunk('#part', 1)).toBe(false)
416
+ })
417
+ })
@@ -0,0 +1,144 @@
1
+ /**
2
+ * #3278 — read-back confirmation probe primitive (outbound-send-path.ts).
3
+ *
4
+ * `classifyReadBackError` maps a raw grammy `editMessageText` FAILURE into the
5
+ * confirmation tri-state, and `createBackstopReadBackProbe` combines a chunk's
6
+ * per-id probes into the chunk verdict `runBackstopDelivery` consumes. These
7
+ * assert OUTCOMES: which error shape means present vs absent vs ambiguous, and
8
+ * that a length-resplit chunk (>1 id) is confirmed only if EVERY id is present.
9
+ */
10
+ import { describe, test, expect, vi } from 'vitest'
11
+ import { GrammyError } from 'grammy'
12
+ import {
13
+ classifyReadBackError,
14
+ createBackstopReadBackProbe,
15
+ createBackstopReadBack,
16
+ } from '../gateway/outbound-send-path.js'
17
+ import type { ReadBackResult } from '../gateway/backstop-delivery.js'
18
+
19
+ // GrammyError's constructor takes (message, payload, method, parameters).
20
+ function grammy(code: number, description: string): GrammyError {
21
+ return new GrammyError(
22
+ `Call to 'editMessageText' failed! (${code}: ${description})`,
23
+ { ok: false, error_code: code, description },
24
+ 'editMessageText',
25
+ {},
26
+ )
27
+ }
28
+
29
+ describe('#3278 classifyReadBackError — raw edit-failure → confirmation tri-state', () => {
30
+ test('400 "message to edit not found" ⇒ absent (positive drop → safe re-send)', () => {
31
+ expect(classifyReadBackError(grammy(400, 'Bad Request: message to edit not found'))).toBe('absent')
32
+ })
33
+
34
+ test('400 "message is not modified" ⇒ exists (a no-op edit → message present)', () => {
35
+ expect(classifyReadBackError(grammy(400, 'Bad Request: message is not modified'))).toBe('exists')
36
+ })
37
+
38
+ test('429 flood ⇒ ambiguous (never re-send)', () => {
39
+ expect(classifyReadBackError(grammy(429, 'Too Many Requests: retry after 30'))).toBe('ambiguous')
40
+ })
41
+
42
+ test('5xx / non-grammy network error ⇒ ambiguous', () => {
43
+ expect(classifyReadBackError(grammy(500, 'Internal Server Error'))).toBe('ambiguous')
44
+ expect(classifyReadBackError(new Error('fetch failed: ECONNRESET'))).toBe('ambiguous')
45
+ })
46
+
47
+ test('an unrelated 400 (e.g. thread not found) is ambiguous, NOT absent', () => {
48
+ // A wrong-classification here would demote-and-re-send a message that may
49
+ // well exist — the never-demote-on-ambiguous rule guards against duplicates.
50
+ expect(classifyReadBackError(grammy(400, 'Bad Request: message thread not found'))).toBe('ambiguous')
51
+ })
52
+ })
53
+
54
+ describe('#3278 createBackstopReadBackProbe — per-chunk id combine', () => {
55
+ test('single id present ⇒ exists; probes the id with the SAME rich body', async () => {
56
+ const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
57
+ const richMessage = vi.fn((s: string) => ({ rich: s }))
58
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage })
59
+ const verdict = await readBack(0, [111], 'answer')
60
+ expect(verdict).toBe('exists')
61
+ expect(richMessage).toHaveBeenCalledWith('answer')
62
+ expect(probeId).toHaveBeenCalledTimes(1)
63
+ expect(probeId).toHaveBeenCalledWith(111, { rich: 'answer' })
64
+ })
65
+
66
+ test('resplit chunk (2 ids): one absent ⇒ absent (whole chunk re-sent)', async () => {
67
+ const probeId = vi.fn(async (id: number): Promise<ReadBackResult> => (id === 12 ? 'absent' : 'exists'))
68
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
69
+ expect(await readBack(1, [11, 12], 'B')).toBe('absent')
70
+ expect(probeId).toHaveBeenCalledTimes(2) // every id of the chunk is probed
71
+ })
72
+
73
+ test('resplit chunk (2 ids): all present ⇒ exists', async () => {
74
+ const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
75
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
76
+ expect(await readBack(0, [1, 2], 'A')).toBe('exists')
77
+ })
78
+
79
+ test('empty id set ⇒ ambiguous and NEVER probes (no confirm, no demote)', async () => {
80
+ const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
81
+ const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
82
+ expect(await readBack(0, [], 'A')).toBe('ambiguous')
83
+ expect(probeId).not.toHaveBeenCalled()
84
+ })
85
+ })
86
+
87
+ const SHED = Symbol.for('switchroom.send-gate.shed')
88
+
89
+ describe('#3278 createBackstopReadBack — full gate+edit+classify wiring', () => {
90
+ function wiring(overrides: Partial<Parameters<typeof createBackstopReadBack>[0]> = {}) {
91
+ return {
92
+ editMessageText: vi.fn(async () => true), // edit applied → exists
93
+ gate: vi.fn(async (fn: () => Promise<unknown>) => fn()), // passthrough gate
94
+ isShed: (r: unknown) => r === SHED,
95
+ richMessage: (s: string) => ({ rich: s }),
96
+ chatId: '1001',
97
+ threadId: undefined as number | undefined,
98
+ ...overrides,
99
+ }
100
+ }
101
+
102
+ test('edit RESOLVES ⇒ exists; edits to the identical rich body (no visible mutation)', async () => {
103
+ const w = wiring()
104
+ const readBack = createBackstopReadBack(w)
105
+ expect(await readBack(0, [55], 'answer')).toBe('exists')
106
+ // Probed the landed id with the SAME rich body the chunk was sent with.
107
+ expect(w.editMessageText).toHaveBeenCalledWith(55, { rich: 'answer' }, expect.anything())
108
+ // Routed through the send gate (pacing) rather than a raw call.
109
+ expect(w.gate).toHaveBeenCalledTimes(1)
110
+ })
111
+
112
+ test('400 message-not-found ⇒ absent (safe re-send)', async () => {
113
+ const editMessageText = vi.fn(async () => {
114
+ throw new GrammyError('boom', { ok: false, error_code: 400, description: 'Bad Request: message to edit not found' }, 'editMessageText', {})
115
+ })
116
+ const readBack = createBackstopReadBack(wiring({ editMessageText }))
117
+ expect(await readBack(0, [55], 'answer')).toBe('absent')
118
+ })
119
+
120
+ test('400 not-modified ⇒ exists (a no-op edit on a present message)', async () => {
121
+ const editMessageText = vi.fn(async () => {
122
+ throw new GrammyError('boom', { ok: false, error_code: 400, description: 'Bad Request: message is not modified' }, 'editMessageText', {})
123
+ })
124
+ const readBack = createBackstopReadBack(wiring({ editMessageText }))
125
+ expect(await readBack(0, [55], 'answer')).toBe('exists')
126
+ })
127
+
128
+ test('429 flood ⇒ ambiguous (never re-send)', async () => {
129
+ const editMessageText = vi.fn(async () => {
130
+ throw new GrammyError('boom', { ok: false, error_code: 429, description: 'Too Many Requests' }, 'editMessageText', {})
131
+ })
132
+ const readBack = createBackstopReadBack(wiring({ editMessageText }))
133
+ expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
134
+ })
135
+
136
+ test('gate SHED (SEND_GATE_SHED sentinel) ⇒ ambiguous, NOT a false exists', async () => {
137
+ // Under a flood window the cosmetic probe sheds — the gate resolves the
138
+ // sentinel, not the edit. Must be ambiguous so the chunk is never demoted.
139
+ const gate = vi.fn(async () => SHED)
140
+ const editMessageText = vi.fn(async () => true)
141
+ const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
142
+ expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
143
+ })
144
+ })