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,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
  })
@@ -620,3 +620,43 @@ describe("ObligationLedger — escalation suppression predicate (Fix 4)", () =>
620
620
  expect(snapshots[1]).toEqual([]);
621
621
  });
622
622
  });
623
+
624
+ // #3282 — the durable captured-answer snapshot attaches to the OPEN obligation
625
+ // so a partial backstop delivery is resumed (tail only) rather than regenerated.
626
+ describe("ObligationLedger — noteCapturedDelivery (#3282)", () => {
627
+ function input(id: string, openedAt: number) {
628
+ return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: 1, text: "q", openedAt };
629
+ }
630
+ const snap = { chunks: ["c0", "c1"], chunkStates: [{ index: 0, messageIds: [10], confirmed: true }] };
631
+
632
+ it("attaches the snapshot to an OPEN obligation and persists it", () => {
633
+ const snapshots: Obligation[][] = [];
634
+ const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
635
+ L.openIfAbsent(input("c:3#1", 1000));
636
+ L.noteCapturedDelivery("c:3#1", snap);
637
+ expect(L.list()[0]!.capturedDelivery).toEqual(snap);
638
+ // The mutation persisted (onChange fired again after open).
639
+ expect(snapshots.at(-1)![0]!.capturedDelivery).toEqual(snap);
640
+ });
641
+
642
+ it("is a no-op for an obligation that is not open (a delivered turn has none to resume)", () => {
643
+ const L = new ObligationLedger(2);
644
+ L.noteCapturedDelivery("c:3#missing", snap); // never opened
645
+ expect(L.isOpen("c:3#missing")).toBe(false);
646
+ });
647
+
648
+ it("ignores an EMPTY snapshot (nothing to resume)", () => {
649
+ const L = new ObligationLedger(2);
650
+ L.openIfAbsent(input("c:3#1", 1000));
651
+ L.noteCapturedDelivery("c:3#1", { chunks: [], chunkStates: [] });
652
+ expect(L.list()[0]!.capturedDelivery).toBeUndefined();
653
+ });
654
+
655
+ it("close drops the snapshot with the obligation (bounded lifetime, A6)", () => {
656
+ const L = new ObligationLedger(2);
657
+ L.openIfAbsent(input("c:3#1", 1000));
658
+ L.noteCapturedDelivery("c:3#1", snap);
659
+ expect(L.close("c:3#1")).toBe(true);
660
+ expect(L.isOpen("c:3#1")).toBe(false);
661
+ });
662
+ });
@@ -118,6 +118,49 @@ describe("obligation-store", () => {
118
118
  expect(loaded[0]!.representCount).toBe(1);
119
119
  });
120
120
 
121
+ it("round-trips a valid capturedDelivery snapshot through persist → load (#3282)", () => {
122
+ const { fs } = memFs();
123
+ const capturedDelivery = {
124
+ chunks: ["c0", "c1"],
125
+ chunkStates: [
126
+ { index: 0, messageIds: [10], confirmed: true },
127
+ { index: 1, messageIds: [11, 12], confirmed: false },
128
+ ],
129
+ };
130
+ persistObligations(PATH, fs, [ob("c:3#715", { capturedDelivery })]);
131
+ const loaded = loadObligations(PATH, fs);
132
+ expect(loaded).toHaveLength(1);
133
+ expect(loaded[0]!.capturedDelivery).toEqual(capturedDelivery);
134
+ });
135
+
136
+ it("STRIPS a malformed capturedDelivery (fail-open to fresh-generation represent) (#3282)", () => {
137
+ // A partial write / forward-incompatible shape must not crash the resume: the
138
+ // row survives (obligation preserved), the bad blob is dropped.
139
+ const bad = {
140
+ v: 1,
141
+ obligations: [
142
+ { ...ob("c:3#715"), capturedDelivery: { chunks: "not-an-array", chunkStates: [] } },
143
+ { ...ob("c:3#716"), capturedDelivery: { chunks: ["c0"], chunkStates: [{ index: 0 }] } },
144
+ ],
145
+ };
146
+ const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
147
+ const loaded = loadObligations(PATH, fs);
148
+ expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:3#716"]);
149
+ expect(loaded[0]!.capturedDelivery).toBeUndefined();
150
+ expect(loaded[1]!.capturedDelivery).toBeUndefined();
151
+ });
152
+
153
+ it("STRIPS an empty-chunks capturedDelivery (nothing to resume → fresh-gen, bounded) (#3282)", () => {
154
+ const bad = {
155
+ v: 1,
156
+ obligations: [{ ...ob("c:3#715"), capturedDelivery: { chunks: [], chunkStates: [] } }],
157
+ };
158
+ const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
159
+ const loaded = loadObligations(PATH, fs);
160
+ expect(loaded).toHaveLength(1);
161
+ expect(loaded[0]!.capturedDelivery).toBeUndefined();
162
+ });
163
+
121
164
  it("never throws on a write failure — degrades to in-memory (logs)", () => {
122
165
  const logs: string[] = [];
123
166
  const fs: ObligationStoreFsSeam = {
@@ -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
 
@@ -143,7 +152,8 @@ describe('resolution clears the durable store (Defect A)', () => {
143
152
 
144
153
  describe('TTL expiry wakes the parked agent (Defect B)', () => {
145
154
  it('the periodic reaper runs the approval-card expiry sweep inside try/catch', () => {
146
- const reaper = slice(GATEWAY, 'const pendingStateReaper = setInterval', 12000)
155
+ // Anchor tolerates the #2996 P0c `isGatewayMain ? setInterval(...)` boot gate.
156
+ const reaper = slice(GATEWAY, 'const pendingStateReaper = ', 12000)
147
157
  // The sweep call must be guarded like the sibling sweepStaleTurnActiveMarker:
148
158
  // an escaped throw inside this setInterval callback reaches uncaughtException
149
159
  // and takes the whole gateway down.
@@ -214,7 +224,7 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
214
224
 
215
225
  describe('vault_request_access copy derives the window from the real TTL', () => {
216
226
  it('no hard-coded "10 min" claim; uses VAULT_REQUEST_ACCESS_TTL_MS', () => {
217
- const fn = slice(GATEWAY, 'async function executeVaultRequestAccess', 12000)
227
+ const fn = slice(CARD_TOOL, 'async function executeVaultRequestAccess', 12000)
218
228
  expect(fn).not.toMatch(/times out \(10 min\)/)
219
229
  expect(fn).toMatch(/Math\.round\(VAULT_REQUEST_ACCESS_TTL_MS \/ 60000\)/)
220
230
  })