switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Golden harness for the extracted narrative/activity lane
3
+ * (#2996 P4-B, plan Amendments 1/5/9/10).
4
+ *
5
+ * ── Oracle standard (Amendment 10) ────────────────────────────────────────
6
+ * gateway.ts cannot be driven in-place from a test runner, so the oracle is
7
+ * the EXTRACTED-MODULE golden harness (the outbound-send-path /
8
+ * stream-render-golden precedent): the REAL `createNarrativeLane` factory is
9
+ * driven directly against a fake bot recorder, real leaf modules
10
+ * (renderActivityFeedWithNested, NarrativeFlushController, narrative-dedup,
11
+ * feed-open-gate), and fake gateway closures.
12
+ *
13
+ * ── What is pinned ────────────────────────────────────────────────────────
14
+ * - narrative SHOW → feed card OPEN (sendRichMessage) with the clipped line
15
+ * - second SHOW → in-place EDIT of the SAME card (no second bubble)
16
+ * - composeTurnActivity: live vs final render (step-count on final only)
17
+ * - the narrative-dedup gate: a trailing draft-of-reply is SUPPRESSED at
18
+ * turn end; genuine trailing narration is SHOWN
19
+ * - clearActivitySummary finalize: terminal EDIT, no delete (default path)
20
+ * - THREE-MODULE cross-surface dedup (Amendment 1): the REAL P4-A
21
+ * handleSessionEvent turn-flush driven with the REAL P4-B lane wired into
22
+ * its deps records into ONE OutboundDedupCache, and the REAL P2 sendReply
23
+ * holding the SAME instance suppresses the late same-text reply
24
+ * - structural: the lane constructs NO OutboundDedupCache / ledger, never
25
+ * reads the `currentTurn` global, and gateway's wrappers delegate lazily
26
+ */
27
+ import { describe, it, expect } from 'vitest'
28
+ import { readFileSync } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { createNarrativeLane } from '../gateway/narrative-lane.js'
31
+ import { handleSessionEvent, type StreamRenderDeps } from '../gateway/stream-render.js'
32
+ import { sendReply, type SendReplyGatewayDeps, type SendReplyRequest } from '../gateway/outbound-send-path.js'
33
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
34
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
35
+ import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
36
+ import { redact } from '../secret-detect/redact.js'
37
+ import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
38
+
39
+ const CHAT = '1001'
40
+
41
+ // ── fake bot recorder ─────────────────────────────────────────────────────
42
+ interface RecordedCall {
43
+ method: string
44
+ chat_id: string
45
+ text: string | null
46
+ message_id?: number
47
+ }
48
+ function makeFakeBot() {
49
+ const calls: RecordedCall[] = []
50
+ let nextId = 3000
51
+ const api = {
52
+ sendRichMessage: async (c: string, b: { markdown: string }) => {
53
+ const id = ++nextId
54
+ calls.push({ method: 'sendRichMessage', chat_id: c, text: b.markdown, message_id: id })
55
+ return { message_id: id }
56
+ },
57
+ sendMessage: async (c: string, t: string) => {
58
+ const id = ++nextId
59
+ calls.push({ method: 'sendMessage', chat_id: c, text: t, message_id: id })
60
+ return { message_id: id }
61
+ },
62
+ editMessageText: async (c: string, m: number, b: unknown) => {
63
+ calls.push({
64
+ method: 'editMessageText', chat_id: c,
65
+ text: typeof b === 'string' ? b : (b as { markdown: string }).markdown, message_id: m,
66
+ })
67
+ return {}
68
+ },
69
+ deleteMessage: async (c: string, m: number) => {
70
+ calls.push({ method: 'deleteMessage', chat_id: c, text: null, message_id: m })
71
+ return true
72
+ },
73
+ }
74
+ return { api, calls }
75
+ }
76
+
77
+ // ── a CurrentTurn sufficient for the lane paths ───────────────────────────
78
+ function makeLaneTurn(lane: ReturnType<typeof createNarrativeLane>, over: Partial<CurrentTurn> = {}): CurrentTurn {
79
+ const turn = {
80
+ turnId: 'turn-lane-1',
81
+ sessionChatId: CHAT,
82
+ sessionThreadId: undefined,
83
+ sourceMessageId: null,
84
+ registryKey: null,
85
+ startedAt: Date.now() - 3000,
86
+ currentModel: null,
87
+ totalTokens: 0,
88
+ labeledToolCount: 0,
89
+ mirrorLines: [] as string[],
90
+ foregroundSubAgents: new Map<string, string[]>(),
91
+ activityPendingRender: null as string | null,
92
+ activityLastSentRender: null as string | null,
93
+ activityMessageId: null as number | null,
94
+ activityInFlight: null as Promise<void> | null,
95
+ activityEverOpened: false,
96
+ activityDrainFailures: 0,
97
+ finalAnswerEverDelivered: false,
98
+ finalAnswerDelivered: false,
99
+ replyCalled: false,
100
+ capturedText: [] as string[],
101
+ lastReplyText: '',
102
+ answerStream: null,
103
+ liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
104
+ ...over,
105
+ } as unknown as CurrentTurn
106
+ ;(turn as { narrativeGate?: unknown }).narrativeGate = lane.makeNarrativeGate(turn)
107
+ return turn
108
+ }
109
+
110
+ // ── lane deps (minimal runtime-faithful fakes; real leaf modules imported by
111
+ // the lane itself) ──────────────────────────────────────────────────────
112
+ function makeLane(opts?: { getCurrentTurn?: () => CurrentTurn | null }) {
113
+ const { api, calls } = makeFakeBot()
114
+ const noop = () => {}
115
+ const fakeEA = {
116
+ mayDrain: () => true,
117
+ openOrEditCard: (_p: string, fn: () => void) => fn(),
118
+ finalizeCard: (fn: () => void) => fn(),
119
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
120
+ }
121
+ const deps = {
122
+ ACTIVITY_CARD_STORE_PATH: `${tmpdir()}/lane-golden-activity-cards.json`,
123
+ CLEAR_STATUS_ON_COMPLETION: false,
124
+ FEED_HEARTBEAT_ENABLED: false,
125
+ FEED_HEARTBEAT_MIN_STALE_MS: 6000,
126
+ FEED_LIVENESS_OPEN_ENABLED: false,
127
+ FEED_LIVENESS_OPEN_MS: 5000,
128
+ PIN_STATUS_WHILE_WORKING: false,
129
+ POST_ANSWER_LIVENESS_STALE_MS: 90000,
130
+ STATIC: false,
131
+ activeDraftStreams: new Map(),
132
+ activityCardPersistEnabled: false,
133
+ activityCardStoreFs: { readFileSync: () => '', writeFileSync: noop, mkdirSync: noop, renameSync: noop, unlinkSync: noop },
134
+ bot: { api },
135
+ cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
136
+ currentTurnMap: { get: () => null, byKey: new Map() },
137
+ earlyLivenessOpenTimers: new Map(),
138
+ emissionAuthorityFor: () => fakeEA,
139
+ feedOpenGateDeps: () => ({ hasOutboundDeliveredSince: () => false, historyEnabled: false, finalAnswerMinChars: 200 }),
140
+ getCurrentTurn: opts?.getCurrentTurn ?? (() => null),
141
+ reconcileStatusPin: noop,
142
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
143
+ statusKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
144
+ } as unknown as NarrativeLaneDeps
145
+ const lane = createNarrativeLane(deps)
146
+ return { lane, calls }
147
+ }
148
+
149
+ const settle = () => new Promise((r) => setTimeout(r, 20))
150
+
151
+ // ── the cases ─────────────────────────────────────────────────────────────
152
+
153
+ describe('narrative-lane golden — SHOW path opens then edits ONE feed card', () => {
154
+ it('first SHOW opens the card (sendRichMessage) with the clipped narrative line', async () => {
155
+ const { lane, calls } = makeLane()
156
+ const turn = makeLaneTurn(lane)
157
+ lane.showNarrativeStep(turn, 'Checking the gateway config for stale entries')
158
+ await turn.activityInFlight
159
+ const sends = calls.filter((c) => c.method === 'sendRichMessage')
160
+ expect(sends).toHaveLength(1)
161
+ expect(sends[0]!.text).toContain('Checking the gateway config for stale entries')
162
+ expect(turn.activityMessageId).toBe(sends[0]!.message_id)
163
+ expect(turn.activityEverOpened).toBe(true)
164
+ })
165
+
166
+ it('second SHOW edits the SAME card in place — never a second bubble', async () => {
167
+ const { lane, calls } = makeLane()
168
+ const turn = makeLaneTurn(lane)
169
+ lane.showNarrativeStep(turn, 'Step one of the work')
170
+ await turn.activityInFlight
171
+ lane.showNarrativeStep(turn, 'Step two of the work')
172
+ await turn.activityInFlight
173
+ expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
174
+ const edits = calls.filter((c) => c.method === 'editMessageText')
175
+ expect(edits.length).toBeGreaterThanOrEqual(1)
176
+ const last = edits[edits.length - 1]!
177
+ expect(last.message_id).toBe(turn.activityMessageId)
178
+ expect(last.text).toContain('Step two of the work')
179
+ })
180
+
181
+ it('composeTurnActivity: live render omits the step total; final render carries it', () => {
182
+ const { lane } = makeLane()
183
+ const turn = makeLaneTurn(lane, { labeledToolCount: 3 })
184
+ turn.mirrorLines.push('Read the file', 'Ran the tests', 'Pushed the branch')
185
+ const live = lane.composeTurnActivity(turn)
186
+ const final = lane.composeTurnActivity(turn, true)
187
+ expect(live).toBeTruthy()
188
+ expect(final).toBeTruthy()
189
+ expect(final!).toMatch(/3 steps/)
190
+ expect(live!).not.toMatch(/3 steps/)
191
+ })
192
+ })
193
+
194
+ describe('narrative-lane golden — the narrative-dedup gate at turn end', () => {
195
+ it('genuine trailing narration is SHOWN (lands in the feed card)', async () => {
196
+ const { lane, calls } = makeLane()
197
+ const turn = makeLaneTurn(lane)
198
+ lane.stagePendingNarrative(turn, 'Done - all checks green, wrapping up.')
199
+ lane.flushPendingNarrativeAtTurnEnd(turn, 'Here is your answer: the fix is deployed.')
200
+ await turn.activityInFlight
201
+ const sends = calls.filter((c) => c.method === 'sendRichMessage')
202
+ expect(sends).toHaveLength(1)
203
+ expect(sends[0]!.text).toContain('Done - all checks green')
204
+ })
205
+
206
+ it('a trailing draft-of-reply is SUPPRESSED (zero card writes)', async () => {
207
+ const { lane, calls } = makeLane()
208
+ const turn = makeLaneTurn(lane)
209
+ const answer = 'The fix is deployed and the tests are green.'
210
+ lane.stagePendingNarrative(turn, answer)
211
+ lane.flushPendingNarrativeAtTurnEnd(turn, answer)
212
+ await settle()
213
+ expect(calls).toHaveLength(0)
214
+ })
215
+ })
216
+
217
+ describe('narrative-lane golden — clearActivitySummary finalize', () => {
218
+ it('finalizes the open card with a terminal EDIT (default: no delete)', async () => {
219
+ const { lane, calls } = makeLane()
220
+ const turn = makeLaneTurn(lane, { labeledToolCount: 1 })
221
+ lane.showNarrativeStep(turn, 'Doing the work now')
222
+ await turn.activityInFlight
223
+ const cardId = turn.activityMessageId
224
+ expect(cardId).not.toBeNull()
225
+ lane.clearActivitySummary(turn)
226
+ await settle()
227
+ const edits = calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)
228
+ expect(edits.length).toBeGreaterThanOrEqual(1)
229
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
230
+ // tracking released so a late render can't resurrect the card
231
+ expect(turn.activityMessageId).toBeNull()
232
+ })
233
+ })
234
+
235
+ // ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
236
+ // The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
237
+ // into its deps, records the delivered answer into ONE OutboundDedupCache;
238
+ // the REAL P2 sendReply holding the SAME instance then suppresses the late
239
+ // same-text reply. One shared cache across all three extracted modules.
240
+
241
+ function makeStreamDepsWithRealLane(dedup: OutboundDedupCache, turn: CurrentTurn, lane: ReturnType<typeof createNarrativeLane>) {
242
+ const { api } = makeFakeBot()
243
+ const noop = () => {}
244
+ const delivered: string[] = []
245
+ const fakeEA = {
246
+ claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
247
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
248
+ finalizeCard: (fn: () => void) => fn(),
249
+ mayDrain: () => true,
250
+ openOrEditCard: (_p: string, fn: () => void) => fn(),
251
+ }
252
+ const deps = {
253
+ ANSWER_LANE: { visibleEnabled: false },
254
+ CAPTURED_PROSE_DELIVERY_ENABLED: false,
255
+ CONTEXT_EXHAUSTION_COOLDOWN_MS: 600000,
256
+ DELIVERY_CONFIRM_ENABLED: false,
257
+ FEED_REOPEN_AFTER_ACK_ENABLED: false,
258
+ HANDBACK_PRETURN_ENABLED: false,
259
+ HISTORY_ENABLED: false,
260
+ LIVENESS_TERMINAL_HONESTY: true,
261
+ OBLIGATION_LEDGER_ENABLED: false,
262
+ ORPHANED_REPLY_STREAM_WINDOW_MS: 120000,
263
+ SILENCE_LIVENESS_PRODUCTION: false,
264
+ STATE_DIR: tmpdir(),
265
+ TURN_FLUSH_SAFETY_ENABLED: true,
266
+ TURN_PREVIEW_MAX: 200,
267
+ activeDraftStreams: new Map(),
268
+ activeStatusReactions: new Map(),
269
+ activeTurnStartedAt: new Map(),
270
+ backstopDeliveryLedger: new BackstopDeliveryLedger(),
271
+ bot: { api },
272
+ flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
273
+ idleTracker: { noteEvent: noop },
274
+ lastPtyPreviewByChat: new Map(),
275
+ obligationLedger: { close: noop, noteTurnEnded: noop },
276
+ outboundDedup: dedup,
277
+ pendingCrossTurnGate: new Map(),
278
+ preambleSuppressor: { dropNow: noop, flushNow: noop, onText: noop, onTool: noop, reset: noop },
279
+ progressDriver: null,
280
+ reactionTransitionCounts: new Map(),
281
+ suppressPtyPreview: new Set(),
282
+ toolFlightTracker: { inFlightCount: () => 0 },
283
+ deliveryQueue: {},
284
+ handbackPreturnSignal: { tryAdopt: () => null },
285
+ sessionModelSource: { noteTranscriptModel: noop },
286
+ typingWrapper: { drainAll: noop, onToolResult: noop, onToolUse: noop },
287
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
288
+ swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
289
+ getCurrentTurn: () => turn,
290
+ getLastContextExhaustionWarningAt: () => 0,
291
+ setLastContextExhaustionWarningAt: noop,
292
+ getPendingPtyPartial: () => null,
293
+ setPendingPtyPartial: noop,
294
+ setCurrentTurn: noop,
295
+ cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
296
+ // ── the REAL P4-B lane wired in (not fakes) ──
297
+ clearActivitySummary: lane.clearActivitySummary,
298
+ closeActivityLane: lane.closeActivityLane,
299
+ closeProgressLane: lane.closeProgressLane,
300
+ composeTurnActivity: lane.composeTurnActivity,
301
+ drainActivitySummary: lane.drainActivitySummary,
302
+ flushPendingNarrativeAtTurnEnd: lane.flushPendingNarrativeAtTurnEnd,
303
+ makeNarrativeGate: lane.makeNarrativeGate,
304
+ resolvePendingNarrativeOnTool: lane.resolvePendingNarrativeOnTool,
305
+ stagePendingNarrative: lane.stagePendingNarrative,
306
+ scheduleEarlyLivenessOpen: lane.scheduleEarlyLivenessOpen,
307
+ // ────────────────────────────────────────────
308
+ clearAnswerReadyFlushTimeout: noop,
309
+ completeProgressCardTurn: null,
310
+ confirmMemoryLegibility: noop,
311
+ deliverAnswer: async (a: { text: string }) => {
312
+ delivered.push(a.text)
313
+ return { sentIds: [4242], chunkCount: 1, delivered: true, exhausted: false }
314
+ },
315
+ deliverCapturedProse: async () => {},
316
+ emissionAuthorityFor: () => fakeEA,
317
+ emitTurnRecord: noop,
318
+ endCurrentTurnAtomic: () => null,
319
+ extractUserPromptPreview: () => null,
320
+ finalizeStatusReaction: noop,
321
+ getPinnedProgressCardMessageId: null,
322
+ handlePtyPartial: noop,
323
+ isDmChatId: () => true,
324
+ isLegitimatelyWorking: () => false,
325
+ promoteQueuedStatus: noop,
326
+ purgeReactionTracking: noop,
327
+ redactOutboundText: (t: string) => redact(t),
328
+ rememberRecentTurn: noop,
329
+ resetAnswerReadyFlushTimeout: noop,
330
+ resetOrphanedReplyTimeout: noop,
331
+ startTurnTypingLoop: noop,
332
+ statusKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
333
+ streamKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
334
+ surfaceMemoryLegibility: noop,
335
+ turnLiveForItsTopic: () => true,
336
+ turnsDb: null,
337
+ unpinProgressCardForChat: null,
338
+ } as unknown as StreamRenderDeps
339
+ return { deps, delivered }
340
+ }
341
+
342
+ function makeSendReplyDeps(dedup: OutboundDedupCache) {
343
+ const { api, calls } = makeFakeBot()
344
+ const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
345
+ const deps = {
346
+ outboundDedup: dedup,
347
+ flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
348
+ firstTextReplyLogged: new Set<string>(),
349
+ suppressPtyPreview: new Set<string>(),
350
+ activeDraftStreams: new Map(),
351
+ lastPtyPreviewByChat: new Map(),
352
+ voiceOnDemandCache: { put: () => {} },
353
+ voicePreSynthQueue: { enqueue: () => {} },
354
+ pendingProgress: { clearPending: () => {}, noteOutbound: () => {} },
355
+ signalTracker: { noteOutbound: () => {}, noteSignal: () => {} },
356
+ silencePoke: { noteOutbound: () => {} },
357
+ getCurrentTurn: () => null,
358
+ getLastActiveTurnChatId: () => undefined,
359
+ HISTORY_ENABLED: false,
360
+ TURN_ORIGIN_ROUTING_ENABLED: true,
361
+ AUTOCLASSIFY_MIDTURN_SHADOW: false,
362
+ MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
363
+ MAX_CHUNK_LIMIT: 4096,
364
+ PHOTO_EXTS: new Set(['.jpg', '.png']),
365
+ lockedBot: { api },
366
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
367
+ swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
368
+ loadAccess: () => ({ allowFrom: [CHAT], groups: {}, disableLinkPreview: true }),
369
+ redactOutboundText: (t: string) => redact(t),
370
+ assertAllowedChat: () => {},
371
+ assertSendable: () => {},
372
+ statusKey: key,
373
+ streamKey: key,
374
+ resolveReplyOwnerTurn: () => null,
375
+ findTurnByOriginId: () => null,
376
+ findTurnByQuotedMessageId: () => null,
377
+ resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
378
+ resolveThreadId: (_c: string, explicit?: string | number | null) => (explicit != null ? Number(explicit) : undefined),
379
+ getLatestInboundMessageId: () => null,
380
+ recordOutbound: () => {},
381
+ emissionAuthorityFor: () => ({
382
+ claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
383
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
384
+ finalizeCard: (fn: () => void) => fn(),
385
+ }),
386
+ clearActivitySummary: () => {},
387
+ startTypingLoop: () => {},
388
+ stopTypingLoop: () => {},
389
+ logOutbound: () => {},
390
+ closeObligationOnSubstantiveReply: () => {},
391
+ finalizeStatusReaction: () => {},
392
+ releaseTurnBufferGate: () => {},
393
+ reapQueuedStatus: () => {},
394
+ noteAgentOutputAt: () => {},
395
+ rememberAgentButtonMeta: () => {},
396
+ resolveVoiceOutPlan: () => null,
397
+ synthesizeVoiceOut: async () => null,
398
+ publishToTelegraph: async () => null,
399
+ clearSilentEndState: () => {},
400
+ emitRuntimeMetric: () => {},
401
+ shadowEmit: () => {},
402
+ progressDriver: null,
403
+ } as unknown as SendReplyGatewayDeps
404
+ return { deps, calls }
405
+ }
406
+ function req(text: string): SendReplyRequest {
407
+ return { args: { chat_id: CHAT, text }, turn: null }
408
+ }
409
+
410
+ describe('THREE-MODULE cross-surface dedup — one OutboundDedupCache across P4-B lane + P4-A stream + P2 reply', () => {
411
+ it('turn-flush through the REAL stream module (REAL lane wired in) records; the REAL sendReply dedups', async () => {
412
+ const dedup = new OutboundDedupCache()
413
+ const { lane } = makeLane()
414
+ const answer = 'The lane-wired terminal answer the model never sent via reply.'
415
+ const turn = makeLaneTurn(lane, { capturedText: [answer], turnId: 'turn-3mod' })
416
+ const { deps } = makeStreamDepsWithRealLane(dedup, turn, lane)
417
+ handleSessionEvent(deps, { kind: 'turn_end', durationMs: 1200 })
418
+ await new Promise((r) => setTimeout(r, 650))
419
+ expect(dedup.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
420
+
421
+ const s = makeSendReplyDeps(dedup)
422
+ const res = await sendReply(s.deps, req(answer))
423
+ expect(res.content[0]!.text).toBe('sent (deduped — same content sent via earlier path)')
424
+ expect(s.calls).toHaveLength(0)
425
+ })
426
+ })
427
+
428
+ describe('structural — singleton + wrapper discipline (#2996 P4-B)', () => {
429
+ const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
430
+ const laneSrc = readFileSync(new URL('../gateway/narrative-lane.ts', import.meta.url), 'utf8')
431
+
432
+ it('the lane constructs NO OutboundDedupCache and NO BackstopDeliveryLedger', () => {
433
+ expect(laneSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
434
+ expect(laneSrc.match(/new BackstopDeliveryLedger\(/g) ?? []).toHaveLength(0)
435
+ // gateway still constructs exactly ONE dedup cache (the injected singleton).
436
+ expect(gatewaySrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(1)
437
+ })
438
+
439
+ it('the lane never reads the currentTurn global — only the injected accessor', () => {
440
+ const codeOnly = laneSrc
441
+ .split('\n')
442
+ .filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
443
+ .join('\n')
444
+ expect(codeOnly).not.toMatch(/[^.\w]currentTurn\b(?!\s*[:,)])/)
445
+ expect(codeOnly).toMatch(/getCurrentTurn\(\)/)
446
+ })
447
+
448
+ it('gateway builds the lane LAZILY with the injected deps and delegates via same-name wrappers', () => {
449
+ expect(gatewaySrc).toContain('narrativeLaneInstance ??= createNarrativeLane(gatewayNarrativeLaneDeps())')
450
+ for (const fn of ['composeTurnActivity', 'drainActivitySummary', 'clearActivitySummary', 'feedHeartbeatTick', 'flushPendingNarrativeAtTurnEnd']) {
451
+ expect(gatewaySrc).toMatch(new RegExp(`narrativeLane\\(\\)\\.${fn}\\(`))
452
+ }
453
+ // the deps builder injects the live-turn accessor (Amendment 9)
454
+ const after = gatewaySrc.split('function gatewayNarrativeLaneDeps(')[1] ?? ''
455
+ const body = after.split('\nexport ')[0] ?? after
456
+ expect(body).toContain('getCurrentTurn: () => currentTurn')
457
+ })
458
+ })
@@ -125,9 +125,15 @@ describe('gateway wiring (source-level guards) — the gateway IIFE is too entan
125
125
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
126
126
  'utf-8',
127
127
  )
128
+ // #2996 P8 PR-B: the funnel bodies moved verbatim to turn-end.ts; the
129
+ // body-structure guards read there (gateway keeps delegating wrappers).
130
+ const turnEndSrc = readFileSync(
131
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
132
+ 'utf-8',
133
+ )
128
134
 
129
135
  it('endCurrentTurnAtomic arms the no-reply drain timer after the serialize-gated purge', () => {
130
- const fn = gatewaySrc.split('function endCurrentTurnAtomic')[1]?.split('\nfunction ')[0] ?? ''
136
+ const fn = turnEndSrc.split('function endCurrentTurnAtomic')[1]?.split('\nfunction ')[0] ?? ''
131
137
  expect(fn).toMatch(/purgeReactionTracking\(/)
132
138
  expect(fn).toMatch(/armNoReplyDrainTimer\(turn\)/)
133
139
  // The arm must come AFTER the purge (the purge attempts the gated
@@ -136,7 +142,7 @@ describe('gateway wiring (source-level guards) — the gateway IIFE is too entan
136
142
  })
137
143
 
138
144
  it('armNoReplyDrainTimer uses the shared predicate + a bounded setTimeout that force-drains', () => {
139
- const fn = gatewaySrc.split('function armNoReplyDrainTimer')[1]?.split('\nfunction ')[0] ?? ''
145
+ const fn = turnEndSrc.split('function armNoReplyDrainTimer')[1]?.split('\nfunction ')[0] ?? ''
140
146
  expect(fn).toMatch(/shouldArmNoReplyDrain\(/)
141
147
  expect(fn).toMatch(/setTimeout\(/)
142
148
  expect(fn).toMatch(/SERIALIZE_NOREPLY_DRAIN_MS/)
@@ -151,6 +157,11 @@ describe('gateway wiring (source-level guards) — the gateway IIFE is too entan
151
157
  // The unwedge fallback calls redeliverBufferedInbound directly — it
152
158
  // must NOT be gated by the serialize predicate, or a stuck no-reply
153
159
  // turn whose bounded timer somehow failed would never recover.
154
- expect(gatewaySrc).toMatch(/silence-poke framework-fallback ended wedged turn/)
160
+ // #2996 P8 PR-C3: the fallback body moved verbatim to liveness-wiring.ts.
161
+ const livenessSrc = readFileSync(
162
+ resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'),
163
+ 'utf-8',
164
+ )
165
+ expect(livenessSrc).toMatch(/silence-poke framework-fallback ended wedged turn/)
155
166
  })
156
167
  })
@@ -30,6 +30,10 @@ const CARD_STORES = read('gateway/approval-card-stores.ts')
30
30
  // moved verbatim behind callback-query-handlers.ts; handler-body pins read
31
31
  // that module while staging/boot/sweep wiring stays pinned on gateway.ts.
32
32
  const CB_HANDLERS = read('gateway/callback-query-handlers.ts')
33
+ // #2996 P5-tail: the agent-facing card-STAGING execute* tool handlers moved
34
+ // verbatim behind card-tool-handlers.ts; their staging pins read that module
35
+ // while the stores/boot/sweep wiring stays pinned on gateway.ts.
36
+ const CARD_TOOL = read('gateway/card-tool-handlers.ts')
33
37
 
34
38
  function slice(src: string, header: string, span = 3000): string {
35
39
  const start = src.indexOf(header)
@@ -62,22 +66,23 @@ describe('pending-card-store wiring', () => {
62
66
 
63
67
  describe('staging persists card metadata (Defect A)', () => {
64
68
  it('vault_request_access stages into the durable store', () => {
65
- const fn = slice(GATEWAY, 'async function executeVaultRequestAccess', 6000)
69
+ const fn = slice(CARD_TOOL, 'async function executeVaultRequestAccess', 6000)
66
70
  expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'vault_request_access'/)
67
71
  })
68
72
 
69
73
  it('request_secret stages into the durable store', () => {
70
- const fn = slice(GATEWAY, 'async function executeRequestSecret', 3000)
74
+ const fn = slice(CARD_TOOL, 'async function executeRequestSecret', 3000)
71
75
  expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'request_secret'/)
72
76
  })
73
77
 
74
78
  it('mental_model_propose stages into the durable store', () => {
75
- const fn = slice(GATEWAY, 'async function executeMentalModelPropose', 6000)
79
+ // 7000: the factory indent puts the add block ~6.1k chars past the def.
80
+ const fn = slice(CARD_TOOL, 'async function executeMentalModelPropose', 7000)
76
81
  expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'mental_model_propose'/)
77
82
  })
78
83
 
79
84
  it('vault_request_save stages metadata but NEVER the secret value (hygiene)', () => {
80
- const fn = slice(GATEWAY, 'async function executeVaultRequestSave', 6000)
85
+ const fn = slice(CARD_TOOL, 'async function executeVaultRequestSave', 6000)
81
86
  const addBlock = slice(fn, "pendingCardStore.add({", 700)
82
87
  expect(addBlock).toMatch(/family: 'vault_request_save'/)
83
88
  // The staged secret `value` must NOT be copied into the persisted record.
@@ -127,9 +132,13 @@ describe('resolution clears the durable store (Defect A)', () => {
127
132
  })
128
133
 
129
134
  it('secret request provide/decline remove from the store', () => {
130
- // capture (provide → value arrives) removes via armed.stageId
135
+ // capture (provide → value arrives) removes via armed.stageId — the value
136
+ // arrival path (captureProvidedSecret) stays in gateway.ts.
131
137
  expect(GATEWAY).toMatch(/pendingCardStore\.remove\(armed\.stageId\)/)
132
- const decline = slice(GATEWAY, 'async function handleSecretRequestCallback', 3000)
138
+ // The decline branch moved with handleSecretRequestCallback into
139
+ // callback-query-handlers.ts (#2996 P5) — pin the body there, mirroring the
140
+ // handleMentalModelProposeCallback pin below.
141
+ const decline = slice(CB_HANDLERS, 'async function handleSecretRequestCallback', 3000)
133
142
  expect(decline).toMatch(/pendingCardStore\.remove\(stageId\)/)
134
143
  })
135
144
 
@@ -215,7 +224,7 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
215
224
 
216
225
  describe('vault_request_access copy derives the window from the real TTL', () => {
217
226
  it('no hard-coded "10 min" claim; uses VAULT_REQUEST_ACCESS_TTL_MS', () => {
218
- const fn = slice(GATEWAY, 'async function executeVaultRequestAccess', 12000)
227
+ const fn = slice(CARD_TOOL, 'async function executeVaultRequestAccess', 12000)
219
228
  expect(fn).not.toMatch(/times out \(10 min\)/)
220
229
  expect(fn).toMatch(/Math\.round\(VAULT_REQUEST_ACCESS_TTL_MS \/ 60000\)/)
221
230
  })
@@ -29,6 +29,17 @@ import { readFileSync } from 'node:fs'
29
29
  import { resolve } from 'node:path'
30
30
 
31
31
  const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
32
+ // #2996 P4-A: the enqueue turn-ctor SET (`setCurrentTurn(next, statusKey(…))`)
33
+ // and two of the three keyed-liveness comparison sites moved VERBATIM into
34
+ // stream-render.ts with handleSessionEvent. In the extracted module the
35
+ // flag-OFF liveness read routes through the injected accessor
36
+ // (`getCurrentTurn() === turn`) rather than the `currentTurn` module global —
37
+ // same invariant, accessor spelling. These wiring oracles span both files.
38
+ const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
39
+ // #2996 P8 PR-C3: the silence-poke fallback body (keyed delete + purgeChatStale
40
+ // self-heal) moved verbatim to liveness-wiring.ts — those oracles read there.
41
+ const livenessSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf-8')
42
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
32
43
 
33
44
  // ---------------------------------------------------------------------------
34
45
  // (a) SOURCE-READ STRUCTURAL ORACLE
@@ -51,19 +62,27 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
51
62
  const rawAssigns = gatewaySrc.match(/^[^/\n]*\bcurrentTurn = (?!currentTurnMap\.get\(\)|null$)/gm) ?? []
52
63
  expect(rawAssigns, `unexpected raw currentTurn assignment(s): ${JSON.stringify(rawAssigns)}`).toEqual([])
53
64
 
54
- // The turn ctor SET routes through setCurrentTurn with the statusKey.
55
- expect(gatewaySrc).toMatch(/setCurrentTurn\(next, statusKey\(ev\.chatId, enqThreadIdNum\)\)/)
65
+ // The turn ctor SET routes through setCurrentTurn with the statusKey
66
+ // (now in stream-render.ts's `enqueue` branch — #2996 P4-A).
67
+ expect(gatewayAndStreamSrc).toMatch(/setCurrentTurn\(next, statusKey\(ev\.chatId, enqThreadIdNum\)\)/)
56
68
  // The disconnect-flush clears the WHOLE map (every entry is a ghost).
57
69
  expect(gatewaySrc).toMatch(/clearAllCurrentTurns\(\)/)
58
- // The silence-poke fallback does a keyed delete for the wedged turn's key.
59
- expect(gatewaySrc).toMatch(/endCurrentTurnForKey\(wedgedTurn, fbKey\)/)
70
+ // The silence-poke fallback does a keyed delete for the wedged turn's key
71
+ // (#2996 P8 PR-C3: the fallback body lives in liveness-wiring.ts).
72
+ expect(livenessSrc).toMatch(/endCurrentTurnForKey\(wedgedTurn, fbKey\)/)
60
73
  })
61
74
 
62
75
  it('endCurrentTurnAtomic closes the leak AT ORIGIN — keyed liveness guard + keyed delete', () => {
63
76
  // Anchor on the `function ` prefix only — the signature is multi-line
64
77
  // (gained an `opts?` param + `number | null` return in the send-honesty
65
78
  // work), and `function ` disambiguates the definition from its call sites.
66
- const body = gatewaySrc
79
+ // #2996 P8 PR-B: the funnel body moved verbatim to turn-end.ts (gateway
80
+ // keeps a thin delegating wrapper) — read the body there.
81
+ const turnEndSrc = readFileSync(
82
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
83
+ 'utf-8',
84
+ )
85
+ const body = turnEndSrc
67
86
  .split('function endCurrentTurnAtomic(')[1]
68
87
  ?.split('\n}')[0] ?? ''
69
88
  expect(body.length).toBeGreaterThan(50)
@@ -88,8 +107,12 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
88
107
  // the flag, resolve THIS turn by ITS OWN topic key. We assert the keyed
89
108
  // form appears the expected number of times AND the literal flag-OFF form is
90
109
  // retained (the silence-liveness-wiring oracle requires `currentTurn === turn`).
91
- const keyedLiveness = gatewaySrc.match(
92
- /EMISSION_AUTHORITY_ENABLED \? turnLiveForItsTopic\(turn\) : currentTurn === turn/g,
110
+ // #2996 P4-A: two of the three sites moved to stream-render.ts, where the
111
+ // flag-OFF read is spelled `getCurrentTurn() === turn` (injected accessor);
112
+ // the one remaining in gateway.ts keeps `currentTurn === turn`. Match either
113
+ // spelling across both files — the population is still exactly 3.
114
+ const keyedLiveness = gatewayAndStreamSrc.match(
115
+ /EMISSION_AUTHORITY_ENABLED \? turnLiveForItsTopic\(turn\) : (?:currentTurn|getCurrentTurn\(\)) === turn/g,
93
116
  ) ?? []
94
117
  expect(keyedLiveness.length).toBe(3)
95
118
  })
@@ -105,7 +128,8 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
105
128
  })
106
129
 
107
130
  it('the self-heal backstop drops per-topic entries for the swept chat (purgeChatStale)', () => {
108
- expect(gatewaySrc).toMatch(/currentTurnMap\.purgeChatStale\(fbChatId,/)
131
+ // #2996 P8 PR-C3: the self-heal lives in the extracted fallback body.
132
+ expect(livenessSrc).toMatch(/currentTurnMap\.purgeChatStale\(fbChatId,/)
109
133
  })
110
134
  })
111
135