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,259 @@
1
+ /**
2
+ * captured-answer-resume.ts — durable captured-answer resume for a partially
3
+ * delivered turn-flush backstop (#3282).
4
+ *
5
+ * ── The bug this closes ──────────────────────────────────────────────────────
6
+ * When the turn-flush backstop (`deliverAnswer` → `runBackstopDelivery`) lands
7
+ * chunks 0,1 of a 3-chunk answer but chunk 2 exhausts its bounded retries, the
8
+ * turn-flush IIFE historically (a) cleared the per-chunk `BackstopDeliveryLedger`
9
+ * unconditionally and (b) left the delivery obligation OPEN. The liveness floor
10
+ * then re-presented the obligation as a FRESH must-answer model turn, which
11
+ * REGENERATED the whole answer — re-posting chunks 0,1's content the user already
12
+ * received. Result: the user sees the head of the answer twice after a partial
13
+ * failure (or after a restart mid-delivery). #3282.
14
+ *
15
+ * ── The fix (design-of-record §2.2) ─────────────────────────────────────────
16
+ * Make the re-present a *byte-identical captured-answer resume* of ONLY the
17
+ * not-yet-`landed-confirmed` tail — never a regeneration — so a chunk that
18
+ * already reached the chat is provably never re-posted:
19
+ *
20
+ * 1. On a partial (`!delivered`) turn-flush, persist a {@link
21
+ * CapturedDeliverySnapshot} (the split chunks + each landed chunk's ids and
22
+ * confirmed flag) onto the durable obligation record, keyed by
23
+ * `originTurnId`. This rides the obligation ledger's existing atomic
24
+ * snapshot, so it survives a gateway/container restart.
25
+ * 2. The obligation sweep's `represent` branch becomes source-aware: when the
26
+ * obligation carries a captured-delivery snapshot it drives THIS resume
27
+ * (re-run `runBackstopDelivery` for the SAME `turnId` and the SAME captured
28
+ * chunks, resuming at the first non-`landed-confirmed` index) instead of
29
+ * pushing a fresh-generation inbound. No snapshot ⇒ the genuine "model wrote
30
+ * nothing / never fired a backstop" case falls through to fresh generation
31
+ * unchanged.
32
+ * 3. The in-memory ledger is rehydrated from the snapshot AND reconciled against
33
+ * the durable outbound-text oracle (`hasOutboundWithText`), so a chunk that
34
+ * landed a durable history row is confirmed even if the snapshot's flag was
35
+ * not yet flushed when the process died (crash-idempotency, design §2.3/A5).
36
+ *
37
+ * Chunk identity is the chunk INDEX within `turnId`, stable across the represent
38
+ * boundary and across restart because the resume re-delivers the exact captured
39
+ * chunks (not a regenerated answer). #3278's per-chunk state machine
40
+ * (`unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`) binds
41
+ * here too: the resume re-PROBES `landed-unconfirmed` chunks (never re-sends
42
+ * them) and re-SENDS only `unsent` ones, so a confirmed chunk is never duplicated
43
+ * by either the in-fire retry OR the cross-represent resume.
44
+ *
45
+ * This module is PURE state + orchestration over injected effects — no Telegram,
46
+ * no SQLite, no gateway module state — so the resume decision is unit-testable
47
+ * (see tests/captured-answer-resume.test.ts). The gateway owns the real
48
+ * `deliver` (via `deliverAnswer`), the durable oracle, and the obligation I/O.
49
+ */
50
+
51
+ import { hasOutboundWithText } from '../history.js'
52
+ import type { BackstopDeliveryLedger } from './backstop-delivery.js'
53
+ import type { CapturedDeliverySnapshot, Obligation } from './obligation-ledger.js'
54
+
55
+ /**
56
+ * Build the durable {@link CapturedDeliverySnapshot} from the live per-chunk
57
+ * ledger after a partial `runBackstopDelivery`. Captures every LANDED chunk's
58
+ * message ids + confirmed flag so the resume can (a) never re-send a confirmed
59
+ * chunk and (b) re-PROBE a landed-unconfirmed chunk instead of blindly re-sending
60
+ * it. Chunks that never landed (send threw) are simply absent → the resume treats
61
+ * them as `unsent` and re-sends them. Pure.
62
+ */
63
+ export function buildCapturedDeliverySnapshot(
64
+ ledger: BackstopDeliveryLedger,
65
+ turnId: string,
66
+ chunks: readonly string[],
67
+ ): CapturedDeliverySnapshot {
68
+ const chunkStates = ledger.entries(turnId).map(({ index, messageIds }) => ({
69
+ index,
70
+ messageIds,
71
+ confirmed: ledger.hasConfirmedChunk(turnId, index),
72
+ }))
73
+ return { chunks: [...chunks], chunkStates }
74
+ }
75
+
76
+ /**
77
+ * Rehydrate a `BackstopDeliveryLedger` for a captured-answer resume from a
78
+ * durable snapshot, reconciled against the durable outbound-text oracle. For each
79
+ * landed chunk in the snapshot:
80
+ * - re-record its landed message id(s) so `unsentIndices` excludes it (the
81
+ * resume never re-sends a chunk that already landed);
82
+ * - mark it `landed-confirmed` when the snapshot flag says so OR the oracle
83
+ * proves its text is a durable outbound row (crash-idempotency, §2.3/A5) —
84
+ * a confirmed chunk is neither re-probed nor re-sent.
85
+ * Chunks absent from the snapshot stay `unsent` (re-sent by the resume). Pure over
86
+ * the injected `hasLanded` oracle.
87
+ */
88
+ export function hydrateResumeLedger(
89
+ ledger: BackstopDeliveryLedger,
90
+ turnId: string,
91
+ snapshot: CapturedDeliverySnapshot,
92
+ hasLanded: (chunkText: string, index: number) => boolean,
93
+ ): void {
94
+ for (const st of snapshot.chunkStates) {
95
+ if (st.messageIds.length === 0) continue // never actually landed → leave unsent
96
+ ledger.recordChunk(turnId, st.index, [...st.messageIds])
97
+ if (st.confirmed || hasLanded(snapshot.chunks[st.index] ?? '', st.index)) {
98
+ ledger.confirmChunk(turnId, st.index)
99
+ }
100
+ }
101
+ }
102
+
103
+ /** The args the module hands the gateway's `deliverAnswer` to run a resume. */
104
+ export interface CapturedResumeArgs {
105
+ chatId: string
106
+ threadId: number | undefined
107
+ text: string
108
+ turnId: string
109
+ cardMessageId: number | null
110
+ replyToMessageId: number | null
111
+ resume: {
112
+ snapshot: CapturedDeliverySnapshot
113
+ hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The RAW gateway singletons the dispatcher wires. Structural (no gateway class
119
+ * imports) and kept minimal so the gateway injection stays a handful of lines
120
+ * under the gateway.ts line ratchet — the module builds every resume closure
121
+ * (the `deliverAnswer` resume args, the oracle-reconciled hydrate, the supersede
122
+ * record) itself.
123
+ */
124
+ export interface CapturedResumePorts {
125
+ /** The gateway's `deliverAnswer`, invoked in RESUME mode (byte-identical tail,
126
+ * no card gate). Only `delivered`/`sentIds` are read. */
127
+ deliverAnswer: (args: CapturedResumeArgs) => Promise<{ delivered: boolean; sentIds: number[] }>
128
+ /** The single obligation ledger. `markRepresented` consumes one represent-budget
129
+ * unit + stamps the per-represent grace (bounding the resume ladder exactly
130
+ * like a fresh-generation represent → escalate at the cap); `close` drops the
131
+ * captured snapshot once fully delivered. */
132
+ obligationLedger: {
133
+ markRepresented: (originTurnId: string) => unknown
134
+ close: (originTurnId: string) => unknown
135
+ }
136
+ /** The per-chunk backstop ledger — GC'd for a fully delivered turn. */
137
+ backstopDeliveryLedger: { clear: (turnId: string) => void }
138
+ /** flushedTurnSupersede — feed the resume's fresh chat ids in so a late reply
139
+ * after the resumed tail still corrects the delivered set (design §4). */
140
+ flushedTurnSupersede: {
141
+ record: (
142
+ chatId: string,
143
+ threadId: number | undefined,
144
+ rec: { turnId: string; messageIds: number[]; text: string },
145
+ nowMs: number,
146
+ ) => void
147
+ }
148
+ /** True when history is queryable (else the durable-text oracle reconcile that
149
+ * gives crash-idempotency — §2.3/A5 — is skipped). */
150
+ historyEnabled: boolean
151
+ stderr?: (s: string) => void
152
+ }
153
+
154
+ export interface CapturedResumeDispatcher {
155
+ /** Fire a captured-answer resume for `o` (fire-and-forget; the sweep is sync).
156
+ * De-duped per `originTurnId` so two sweeps never launch concurrent resumes;
157
+ * a no-op when `o` carries no non-empty captured snapshot (the caller should
158
+ * then fall through to fresh generation). */
159
+ dispatch: (o: Obligation) => void
160
+ /** In-flight origin ids (test introspection). */
161
+ inFlight: () => string[]
162
+ }
163
+
164
+ /**
165
+ * The obligation-sweep's captured-answer resume driver. Owns the in-flight guard
166
+ * + the deliver→close/leave-open orchestration so the gateway sweep stays a
167
+ * two-line dispatch. On each dispatch it consumes one represent-budget unit
168
+ * (bounding the ladder → escalate), re-delivers only the non-confirmed tail, and:
169
+ * - fully delivered ⇒ record the supersede tail, close the obligation, GC the
170
+ * ledger (the represent ladder stops);
171
+ * - still partial / error ⇒ leave the obligation OPEN so the next eligible
172
+ * sweep (after the per-represent grace) retries, until the represent cap
173
+ * escalates it to the operator nudge — no infinite loop.
174
+ */
175
+ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): CapturedResumeDispatcher {
176
+ const stderr = ports.stderr ?? (() => {})
177
+ const inFlight = new Set<string>()
178
+
179
+ /** Re-deliver the non-confirmed tail of `o`'s captured answer, byte-identical,
180
+ * by rehydrating the per-chunk ledger from the snapshot reconciled against the
181
+ * durable text oracle. Never regenerates. */
182
+ function deliver(o: Obligation, snapshot: CapturedDeliverySnapshot): Promise<{ delivered: boolean; sentIds: number[] }> {
183
+ return ports.deliverAnswer({
184
+ chatId: o.chatId,
185
+ threadId: o.threadId,
186
+ text: '',
187
+ turnId: o.originTurnId,
188
+ cardMessageId: null,
189
+ replyToMessageId: o.messageId,
190
+ resume: {
191
+ snapshot,
192
+ hydrate: (ledger, turnId) =>
193
+ hydrateResumeLedger(ledger, turnId, snapshot, (txt) =>
194
+ ports.historyEnabled &&
195
+ hasOutboundWithText(o.chatId, txt, o.threadId ?? null, o.openedAt),
196
+ ),
197
+ },
198
+ })
199
+ }
200
+
201
+ /** Record the resume's FULL delivered id set (rehydrated-confirmed + freshly
202
+ * re-sent — `runBackstopDelivery` returns `ledger.sentIds`, i.e. all landed
203
+ * ids in index order) into the supersede lane, keyed by the turn id. This
204
+ * REPLACES the partial turn-flush record in place with the complete set, so a
205
+ * late reply for this turn corrects the WHOLE delivered answer (design §4) —
206
+ * no-op when nothing has landed. */
207
+ function supersede(o: Obligation, snapshot: CapturedDeliverySnapshot, sentIds: number[]): void {
208
+ if (sentIds.length === 0) return
209
+ ports.flushedTurnSupersede.record(
210
+ o.chatId,
211
+ o.threadId,
212
+ { turnId: o.originTurnId, messageIds: sentIds, text: snapshot.chunks.join('') },
213
+ Date.now(),
214
+ )
215
+ }
216
+
217
+ async function run(o: Obligation, snapshot: CapturedDeliverySnapshot): Promise<void> {
218
+ try {
219
+ const { delivered, sentIds } = await deliver(o, snapshot)
220
+ supersede(o, snapshot, sentIds)
221
+ if (delivered) {
222
+ ports.obligationLedger.close(o.originTurnId)
223
+ ports.backstopDeliveryLedger.clear(o.originTurnId)
224
+ stderr(
225
+ `telegram gateway: captured-answer resume delivered — origin=${o.originTurnId} ` +
226
+ `${sentIds.length} chunk(s) confirmed; obligation closed\n`,
227
+ )
228
+ } else {
229
+ // Tail still not confirmed — leave the obligation OPEN for the next paced
230
+ // sweep / escalation (bounded by the represent cap).
231
+ stderr(
232
+ `telegram gateway: captured-answer resume partial — origin=${o.originTurnId} ` +
233
+ `tail still not confirmed; left OPEN for retry\n`,
234
+ )
235
+ }
236
+ } catch (err) {
237
+ stderr(
238
+ `telegram gateway: captured-answer resume error — origin=${o.originTurnId}: ` +
239
+ `${err instanceof Error ? err.message : String(err)}\n`,
240
+ )
241
+ } finally {
242
+ inFlight.delete(o.originTurnId)
243
+ }
244
+ }
245
+
246
+ return {
247
+ dispatch(o: Obligation): void {
248
+ const snapshot = o.capturedDelivery
249
+ if (snapshot == null || snapshot.chunks.length === 0) return
250
+ if (inFlight.has(o.originTurnId)) return
251
+ inFlight.add(o.originTurnId)
252
+ // Consume represent budget + arm the per-represent grace up front so the
253
+ // ladder is bounded and the sweep can't immediately re-dispatch mid-flight.
254
+ ports.obligationLedger.markRepresented(o.originTurnId)
255
+ void run(o, snapshot)
256
+ },
257
+ inFlight: () => [...inFlight],
258
+ }
259
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildVaultRequestAccessKeyboard } from './vault-request-access-card.js'
3
+ import { buildMentalModelProposeKeyboard } from './mental-model-propose-card.js'
4
+
5
+ // The approve/deny keyboards for the vault-access (vra:) and mental-model
6
+ // proposal (mmp:) cards moved out of gateway.ts in #2996 P5. Their callback_data
7
+ // prefixes are the contract the callback dispatch table in gateway.ts + the
8
+ // handlers in callback-query-handlers.ts route on — pin them so a rename here
9
+ // can't silently break the tap routing.
10
+ describe('approval-card keyboards (#2996 P5 extraction)', () => {
11
+ it('buildVaultRequestAccessKeyboard carries the vra:* contract', () => {
12
+ const flat = buildVaultRequestAccessKeyboard('abcd1234').inline_keyboard.flat()
13
+ expect(flat.map((b) => b.callback_data)).toEqual([
14
+ 'vra:approve:abcd1234',
15
+ 'vra:deny:abcd1234',
16
+ ])
17
+ expect(flat.map((b) => b.text)).toEqual(['✅ Approve', '🚫 Deny'])
18
+ })
19
+
20
+ it('buildMentalModelProposeKeyboard carries the mmp:* contract', () => {
21
+ const flat = buildMentalModelProposeKeyboard('abcd1234').inline_keyboard.flat()
22
+ expect(flat.map((b) => b.callback_data)).toEqual([
23
+ 'mmp:approve:abcd1234',
24
+ 'mmp:deny:abcd1234',
25
+ ])
26
+ expect(flat.map((b) => b.text)).toEqual(['✅ Approve', '🚫 Deny'])
27
+ })
28
+ })