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
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Structural test for the `bot.on('message:pinned_message')` handler that
3
- * suppresses the "pinned a message" service message Telegram inserts when
4
- * OUR silent status-pin fires.
2
+ * Structural test for the `message:pinned_message` handler that suppresses the
3
+ * "pinned a message" service message Telegram inserts when OUR silent status-pin
4
+ * fires.
5
5
  *
6
- * Why structural: like every other gateway handler, this closure is wired
7
- * inline against the live `bot` instance and is not exported β€” a functional
8
- * invocation would require booting the full grammy runtime against a mocked
9
- * Bot API. The gateway suite settled on file-level grep assertions for
10
- * exactly this reason (see `inbound-message-types.test.ts`). The regression
11
- * we care about: a future hand dropping the handler, dropping the ownership
12
- * guard (which would let us delete manual/operator pins), or swapping the
13
- * house deletion wrapper for a raw `bot.api.deleteMessage` (allowlist drift).
6
+ * As of switchroom#2996 P6 cluster F the handler body lives in the extracted
7
+ * `gateway/pinned-message-handler.ts` module (unit-tested for real in
8
+ * `pinned-message-handler.test.ts`); gateway.ts keeps only the thin
9
+ * `bot.on('message:pinned_message', …)` delegation plus the injected
10
+ * `deleteServiceMessage` binding that wraps the raw `lockedBot.api.deleteMessage`
11
+ * in `robustApiCall`. The regressions this structural test still guards: a hand
12
+ * dropping the delegation, dropping the ownership guard (which would let us
13
+ * delete manual/operator pins), or moving the raw delete out from under the
14
+ * robust wrapper (allowlist drift).
14
15
  */
15
16
 
16
17
  import { describe, it, expect } from 'vitest'
@@ -25,66 +26,58 @@ const SRC = readFileSync(
25
26
  'utf8',
26
27
  )
27
28
 
28
- /**
29
- * Extract the body of the `bot.on('message:pinned_message', …)` handler:
30
- * from the `bot.on(` line to the matching outer-scope closing brace.
31
- */
32
- function pinnedMessageHandler(): string {
33
- const needle = `bot.on('message:pinned_message'`
34
- const start = SRC.indexOf(needle)
35
- expect(start, `handler ${needle} not found`).toBeGreaterThan(0)
36
- const firstBrace = SRC.indexOf('{', start)
37
- let depth = 0
38
- for (let i = firstBrace; i < SRC.length; i++) {
39
- const c = SRC[i]
40
- if (c === '{') depth++
41
- else if (c === '}') {
42
- depth--
43
- if (depth === 0) return SRC.slice(start, i + 1)
44
- }
45
- }
46
- throw new Error('could not find end of pinned_message handler')
47
- }
29
+ const MODULE_SRC = readFileSync(
30
+ new URL('../gateway/pinned-message-handler.ts', import.meta.url),
31
+ 'utf8',
32
+ )
48
33
 
49
34
  describe('status-pin service-message suppression', () => {
50
- it("registers a bot.on('message:pinned_message') handler", () => {
35
+ it("registers a bot.on('message:pinned_message') delegation to the extracted handler", () => {
51
36
  expect(SRC).toContain(`bot.on('message:pinned_message'`)
37
+ // The registration is now a thin delegation into the extracted module.
38
+ expect(SRC).toContain('handlePinnedMessage(ctx, pinnedMessageHandlerDeps)')
52
39
  })
53
40
 
54
- const body = pinnedMessageHandler()
55
-
56
41
  it('guards on ownership via the chat-scoped pinnedMessageIsOurs helper', () => {
57
42
  // Must consult our tracked pins β€” never blindly delete a pin service
58
43
  // message (which would nuke manual/operator pins too) β€” AND it must be
59
44
  // chat-scoped (pinnedMessageIsOurs requires chatId + messageId), never a
60
45
  // messageId-only match.
61
- expect(body).toContain('statusPinState')
62
- expect(body).toMatch(/pinned_message\?\.message_id/)
63
- expect(body).toContain('pinnedMessageIsOurs(')
46
+ expect(MODULE_SRC).toContain('deps.statusPinState')
47
+ expect(MODULE_SRC).toMatch(/pinned_message\?\.message_id/)
48
+ expect(MODULE_SRC).toContain('pinnedMessageIsOurs(')
64
49
  // The chatId computed from the update must be fed to the guard.
65
- expect(body).toMatch(/pinnedMessageIsOurs\(trackedPins\(\), chatId, pinnedId\)/)
50
+ expect(MODULE_SRC).toMatch(/pinnedMessageIsOurs\(trackedPins\(\), chatId, pinnedId\)/)
66
51
  // And the tracked entries must carry their chat association.
67
- expect(body).toContain('statusPinChatIds.get(pinKey)')
52
+ expect(MODULE_SRC).toContain('deps.statusPinChatIds.get(pinKey)')
68
53
  })
69
54
 
70
55
  it('bails out when the pinned message is not one of ours', () => {
71
56
  // A `return` guard for the non-owned case must exist.
72
- expect(body).toMatch(/if \(!isOurs\(\)\) return/)
57
+ expect(MODULE_SRC).toMatch(/if \(!isOurs\(\)\) return/)
73
58
  })
74
59
 
75
60
  it('deletes the service message through the robust wrapper (not a raw api call)', () => {
76
- expect(body).toContain('robustApiCall(')
77
- expect(body).toContain('deleteMessage')
78
- // The message deleted is the service message itself, not the pinned msg.
79
- expect(body).toContain('serviceMsgId')
80
- // House verb tag so operators can trace it.
81
- expect(body).toContain("verb: 'status-pin.delete-service-message'")
61
+ // The extracted module carries no raw Bot API site β€” it calls the injected
62
+ // `deleteServiceMessage`, which gateway.ts binds through robustApiCall.
63
+ expect(MODULE_SRC).toContain('deps.deleteServiceMessage(chatId, serviceMsgId)')
64
+ // The module makes no raw Bot API call outside a comment (the docstring
65
+ // names the wrapped call as an example). Strip comment lines, then assert
66
+ // no `lockedBot.api.*(` invocation survives in executable code.
67
+ const codeOnly = MODULE_SRC.split('\n')
68
+ .filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l))
69
+ .join('\n')
70
+ expect(codeOnly).not.toMatch(/lockedBot\.api\.\w+\(/)
71
+ // gateway.ts owns the robust wrapper + house verb tag.
72
+ expect(SRC).toContain('robustApiCall(')
73
+ expect(SRC).toContain('lockedBot.api.deleteMessage(chatId, serviceMsgId)')
74
+ expect(SRC).toContain("verb: 'status-pin.delete-service-message'")
82
75
  })
83
76
 
84
77
  it('tolerates the reconcile-store race with a short retry', () => {
85
78
  // The service update can arrive before reconcileStatusPin stores the
86
79
  // PinState; a single delayed re-check covers that window.
87
- expect(body).toContain('setTimeout')
80
+ expect(MODULE_SRC).toContain('setTimeout')
88
81
  })
89
82
  })
90
83
 
@@ -199,9 +192,9 @@ describe('service-message deletion in supergroups / forum topics', () => {
199
192
 
200
193
  it('the handler logs a missing-admin-right hint on delete failure (supergroup)', () => {
201
194
  // Structural: the catch block names the likely supergroup cause so an
202
- // operator isn't left with silent nothing.
195
+ // operator isn't left with silent nothing. Lives in the extracted module.
203
196
  const src = readFileSync(
204
- new URL('../gateway/gateway.ts', import.meta.url),
197
+ new URL('../gateway/pinned-message-handler.ts', import.meta.url),
205
198
  'utf8',
206
199
  )
207
200
  expect(src).toContain('could not delete pin service message')
@@ -17,6 +17,13 @@ import { switchroomHelpCommandNames, switchroomHelpText } from '../welcome-text.
17
17
 
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url))
19
19
  const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
20
+ // P7 PR-2 (#2996): the handleInbound stop-keyword intercept block moved into
21
+ // inbound-interceptors.ts (`interceptStopKeyword`). The coalescer bypass stays
22
+ // in gateway.ts. Scrapes anchor on whichever file now owns each literal.
23
+ const INTERCEPTORS_SRC = readFileSync(
24
+ resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'),
25
+ 'utf8',
26
+ )
20
27
 
21
28
  describe('parseStopKeyword', () => {
22
29
  it('matches the exact word stop, case-insensitively', () => {
@@ -177,18 +184,20 @@ describe('gateway wiring β€” /stop cancels the in-flight turn (#3020)', () => {
177
184
  expect(bypassIdx).toBeGreaterThan(0)
178
185
  const bypassWin = GATEWAY_SRC.slice(bypassIdx, bypassIdx + 800)
179
186
  expect(bypassWin).toContain('parseStopKeyword(text)')
180
- // handleInbound keyword block: honest idle reply or halt + confirmation.
181
- const kwIdx = GATEWAY_SRC.indexOf("executeHaltNow('stop-keyword')")
187
+ // interceptStopKeyword block (P7 PR-2, inbound-interceptors.ts): honest
188
+ // idle reply or halt + confirmation.
189
+ const kwIdx = INTERCEPTORS_SRC.indexOf("deps.executeHaltNow('stop-keyword')")
182
190
  expect(kwIdx).toBeGreaterThan(0)
183
- const kwWin = GATEWAY_SRC.slice(kwIdx - 1600, kwIdx + 900)
184
- expect(kwWin).toContain('turnInFlightForGate()')
191
+ const kwWin = INTERCEPTORS_SRC.slice(kwIdx - 1600, kwIdx + 900)
192
+ expect(kwWin).toContain('deps.turnInFlightForGate()')
185
193
  expect(kwWin).toContain('buildStopReply(inFlight, queuedLabels)')
186
194
  // Intercepted β€” never forwarded to the agent as a turn.
187
- expect(kwWin).toContain('return')
195
+ expect(kwWin).toContain('return { handled: true }')
188
196
  })
189
197
 
190
198
  it('the empty-`!` interrupt routes through the same executeHaltNow helper', () => {
191
- expect(GATEWAY_SRC).toContain("executeHaltNow('bang-empty')")
199
+ // P7 PR-3: the `!`-marker intercept moved to inbound-interceptors.ts.
200
+ expect(INTERCEPTORS_SRC).toContain("deps.executeHaltNow('bang-empty')")
192
201
  })
193
202
 
194
203
  it('/stop with an argument warns and does NOT halt (old container-stop muscle memory)', () => {
@@ -210,12 +219,13 @@ describe('gateway wiring β€” /stop cancels the in-flight turn (#3020)', () => {
210
219
  'parseStopKeyword(text) && downloadImage == null && attachment == null',
211
220
  )
212
221
  expect(bypassIdx).toBeGreaterThan(0)
213
- // handleInbound keyword block: also excludes coalesced extra attachments.
214
- const kwIdx = GATEWAY_SRC.indexOf("executeHaltNow('stop-keyword')")
215
- const kwWin = GATEWAY_SRC.slice(kwIdx - 2400, kwIdx)
216
- expect(kwWin).toContain('downloadImage == null')
217
- expect(kwWin).toContain('attachment == null')
218
- expect(kwWin).toContain('extraAttachments == null || extraAttachments.length === 0')
222
+ // interceptStopKeyword block (P7 PR-2): also excludes coalesced extra
223
+ // attachments (per-message facts arrive as the `p.` params object).
224
+ const kwIdx = INTERCEPTORS_SRC.indexOf("deps.executeHaltNow('stop-keyword')")
225
+ const kwWin = INTERCEPTORS_SRC.slice(kwIdx - 2400, kwIdx)
226
+ expect(kwWin).toContain('p.downloadImage == null')
227
+ expect(kwWin).toContain('p.attachment == null')
228
+ expect(kwWin).toContain('p.extraAttachments == null || p.extraAttachments.length === 0')
219
229
  })
220
230
  })
221
231
 
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Golden-transcript harness for the extracted stream/render dispatcher
3
+ * (#2996 P4-A, plan Amendments 1/5/9/10).
4
+ *
5
+ * ── Oracle standard (Amendment 10) ────────────────────────────────────────
6
+ * gateway.ts cannot be driven in-place from a test runner (`handleSessionEvent`
7
+ * was unexported; `bot` is assigned only inside the isGatewayMain boot). The
8
+ * coordinator-accepted oracle is therefore the EXTRACTED-MODULE golden harness
9
+ * (the merged send-reply-golden precedent): the real `handleSessionEvent` is
10
+ * driven with synthetic SessionEvent sequences against a fake bot recorder +
11
+ * the REAL `OutboundDedupCache`, with gateway-scoped closures faked.
12
+ *
13
+ * ── The load-bearing case (Amendment 1, BLOCKING) ─────────────────────────
14
+ * CROSS-SURFACE stream-then-reply dedup spanning BOTH extracted modules on ONE
15
+ * shared `OutboundDedupCache`: the REAL P4 `handleSessionEvent` turn-flush path
16
+ * records the delivered answer into the cache, then the REAL P2 `sendReply`
17
+ * (a DIFFERENT module) checking that SAME instance suppresses a late same-text
18
+ * reply β€” zero wire calls. A negative control proves a second cache reinstates
19
+ * the duplicate. This is the exact duplicate-reply class the shared-singleton
20
+ * injection (never a re-`new`) exists to kill.
21
+ */
22
+ import { describe, it, expect } from 'vitest'
23
+ import { readFileSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { handleSessionEvent, type StreamRenderDeps } from '../gateway/stream-render.js'
26
+ import {
27
+ sendReply,
28
+ type SendReplyGatewayDeps,
29
+ type SendReplyRequest,
30
+ } from '../gateway/outbound-send-path.js'
31
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
32
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
33
+ import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
34
+ import { redact } from '../secret-detect/redact.js'
35
+ import type { CurrentTurn } from '../gateway/gateway.js'
36
+
37
+ const CHAT = '1001'
38
+
39
+ // ── fake bot recorder (shared shape with send-reply-golden) ────────────────
40
+ interface RecordedCall {
41
+ method: string
42
+ chat_id: string
43
+ text: string | null
44
+ opts: Record<string, unknown>
45
+ reply_markup: unknown
46
+ message_id?: number
47
+ }
48
+ function makeFakeBot() {
49
+ const calls: RecordedCall[] = []
50
+ let nextId = 2000
51
+ const rec = (method: string, chat_id: string, text: string | null, opts: Record<string, unknown> = {}) => {
52
+ const id = ++nextId
53
+ calls.push({ method, chat_id, text, opts, reply_markup: opts.reply_markup ?? null, message_id: id })
54
+ return { message_id: id }
55
+ }
56
+ const api = {
57
+ sendRichMessage: async (c: string, b: { markdown: string }, o: Record<string, unknown> = {}) => rec('sendRichMessage', c, b.markdown, o),
58
+ sendMessage: async (c: string, t: string, o: Record<string, unknown> = {}) => rec('sendMessage', c, t, o),
59
+ editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => { rec('editMessageText', c, typeof b === 'string' ? b : (b as { markdown: string }).markdown, o); return {} },
60
+ deleteMessage: async (c: string, m: number) => { rec('deleteMessage', c, null); return true },
61
+ }
62
+ return { api, calls }
63
+ }
64
+
65
+ // ── a CurrentTurn sufficient for the turn-flush record path ────────────────
66
+ function makeTurn(over: Partial<CurrentTurn> = {}): CurrentTurn {
67
+ return {
68
+ turnId: 'turn-flush-1',
69
+ sessionChatId: CHAT,
70
+ sessionThreadId: undefined,
71
+ registryKey: null,
72
+ sourceMessageId: null,
73
+ startedAt: Date.now() - 5000,
74
+ replyCalled: false,
75
+ lastReplyText: '',
76
+ answerStream: null,
77
+ capturedText: ['The composed terminal answer that the model never sent via reply.'],
78
+ capturedBlockMeta: [false],
79
+ finalAnswerDelivered: false,
80
+ finalAnswerSubstantive: false,
81
+ answerDelivered: false,
82
+ deliveryOutcome: undefined,
83
+ orphanedReplyTimeoutId: null,
84
+ orphanedReplyStreamMsgId: null,
85
+ activityPendingRender: null,
86
+ liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
87
+ ...over,
88
+ } as unknown as CurrentTurn
89
+ }
90
+
91
+ // ── the full injected deps, minimal but runtime-faithful fakes ─────────────
92
+ interface StreamHarness {
93
+ deps: StreamRenderDeps
94
+ calls: RecordedCall[]
95
+ dedup: OutboundDedupCache
96
+ ledger: BackstopDeliveryLedger
97
+ delivered: string[]
98
+ }
99
+ function makeStreamDeps(opts?: {
100
+ dedup?: OutboundDedupCache
101
+ turn?: CurrentTurn | null
102
+ deliverResult?: { sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }
103
+ }): StreamHarness {
104
+ const { api, calls } = makeFakeBot()
105
+ const dedup = opts?.dedup ?? new OutboundDedupCache()
106
+ const ledger = new BackstopDeliveryLedger()
107
+ const delivered: string[] = []
108
+ let curTurn = opts?.turn ?? null
109
+ const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
110
+ const noop = () => {}
111
+ const fakeEA = {
112
+ claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
113
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
114
+ finalizeCard: (fn: () => void) => fn(),
115
+ }
116
+ const deps = {
117
+ // config
118
+ ANSWER_LANE: { visibleEnabled: false } as unknown,
119
+ CAPTURED_PROSE_DELIVERY_ENABLED: false,
120
+ CONTEXT_EXHAUSTION_COOLDOWN_MS: 600000,
121
+ DELIVERY_CONFIRM_ENABLED: false,
122
+ FEED_REOPEN_AFTER_ACK_ENABLED: false,
123
+ HANDBACK_PRETURN_ENABLED: false,
124
+ HISTORY_ENABLED: false,
125
+ LIVENESS_TERMINAL_HONESTY: true,
126
+ OBLIGATION_LEDGER_ENABLED: false,
127
+ ORPHANED_REPLY_STREAM_WINDOW_MS: 120000,
128
+ SILENCE_LIVENESS_PRODUCTION: false,
129
+ STATE_DIR: tmpdir(),
130
+ TURN_FLUSH_SAFETY_ENABLED: true,
131
+ TURN_PREVIEW_MAX: 200,
132
+ // state singletons
133
+ activeDraftStreams: new Map(),
134
+ activeStatusReactions: new Map(),
135
+ activeTurnStartedAt: new Map(),
136
+ backstopDeliveryLedger: ledger,
137
+ bot: { api },
138
+ flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
139
+ idleTracker: { noteEvent: noop },
140
+ lastPtyPreviewByChat: new Map(),
141
+ obligationLedger: { close: noop, noteTurnEnded: noop },
142
+ outboundDedup: dedup,
143
+ pendingCrossTurnGate: new Map(),
144
+ preambleSuppressor: { dropNow: noop, flushNow: noop, onText: noop, onTool: noop, reset: noop },
145
+ progressDriver: null,
146
+ reactionTransitionCounts: new Map(),
147
+ suppressPtyPreview: new Set(),
148
+ toolFlightTracker: { inFlightCount: () => 0 },
149
+ deliveryQueue: {},
150
+ handbackPreturnSignal: { tryAdopt: () => null },
151
+ sessionModelSource: { noteTranscriptModel: noop },
152
+ typingWrapper: { drainAll: noop, onToolResult: noop, onToolUse: noop },
153
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
154
+ swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
155
+ // volatile accessors
156
+ getCurrentTurn: () => curTurn,
157
+ getLastContextExhaustionWarningAt: () => 0,
158
+ setLastContextExhaustionWarningAt: noop,
159
+ getPendingPtyPartial: () => null,
160
+ setPendingPtyPartial: noop,
161
+ setCurrentTurn: (t: CurrentTurn) => { curTurn = t },
162
+ // local-closure fakes
163
+ cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
164
+ clearActivitySummary: noop,
165
+ clearAnswerReadyFlushTimeout: noop,
166
+ closeActivityLane: noop,
167
+ closeProgressLane: noop,
168
+ completeProgressCardTurn: null,
169
+ composeTurnActivity: () => null,
170
+ confirmMemoryLegibility: noop,
171
+ deliverAnswer: async (a: { text: string }) => {
172
+ delivered.push(a.text)
173
+ return opts?.deliverResult ?? { sentIds: [4242], chunkCount: 1, delivered: true, exhausted: false }
174
+ },
175
+ deliverCapturedProse: async () => {},
176
+ drainActivitySummary: async () => {},
177
+ emissionAuthorityFor: () => fakeEA,
178
+ emitTurnRecord: noop,
179
+ endCurrentTurnAtomic: () => null,
180
+ extractUserPromptPreview: () => null,
181
+ finalizeStatusReaction: noop,
182
+ flushPendingNarrativeAtTurnEnd: noop,
183
+ getPinnedProgressCardMessageId: null,
184
+ handlePtyPartial: noop,
185
+ isDmChatId: () => true,
186
+ isLegitimatelyWorking: () => false,
187
+ makeNarrativeGate: () => ({ show: noop, stage: noop, resolveOnTool: noop, flushAtTurnEnd: noop }),
188
+ promoteQueuedStatus: noop,
189
+ purgeReactionTracking: noop,
190
+ redactOutboundText: (t: string) => redact(t),
191
+ rememberRecentTurn: noop,
192
+ resetAnswerReadyFlushTimeout: noop,
193
+ resetOrphanedReplyTimeout: noop,
194
+ resolvePendingNarrativeOnTool: noop,
195
+ scheduleEarlyLivenessOpen: noop,
196
+ stagePendingNarrative: noop,
197
+ startTurnTypingLoop: noop,
198
+ statusKey: key,
199
+ streamKey: key,
200
+ surfaceMemoryLegibility: noop,
201
+ turnLiveForItsTopic: () => true,
202
+ turnsDb: null,
203
+ unpinProgressCardForChat: null,
204
+ } as unknown as StreamRenderDeps
205
+ return { deps, calls, dedup, ledger, delivered }
206
+ }
207
+
208
+ // ── the P2 sendReply harness (same content, sharing the ONE cache) ─────────
209
+ function makeSendReplyDeps(dedup: OutboundDedupCache) {
210
+ const { api, calls } = makeFakeBot()
211
+ const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
212
+ const deps = {
213
+ outboundDedup: dedup,
214
+ flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
215
+ firstTextReplyLogged: new Set<string>(),
216
+ suppressPtyPreview: new Set<string>(),
217
+ activeDraftStreams: new Map(),
218
+ lastPtyPreviewByChat: new Map(),
219
+ voiceOnDemandCache: { put: () => {} },
220
+ voicePreSynthQueue: { enqueue: () => {} },
221
+ pendingProgress: { clearPending: () => {}, noteOutbound: () => {} },
222
+ signalTracker: { noteOutbound: () => {}, noteSignal: () => {} },
223
+ silencePoke: { noteOutbound: () => {} },
224
+ getCurrentTurn: () => null,
225
+ getLastActiveTurnChatId: () => undefined,
226
+ HISTORY_ENABLED: false,
227
+ TURN_ORIGIN_ROUTING_ENABLED: true,
228
+ AUTOCLASSIFY_MIDTURN_SHADOW: false,
229
+ MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
230
+ MAX_CHUNK_LIMIT: 4096,
231
+ PHOTO_EXTS: new Set(['.jpg', '.png']),
232
+ lockedBot: { api },
233
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
234
+ swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
235
+ loadAccess: () => ({ allowFrom: [CHAT], groups: {}, disableLinkPreview: true }),
236
+ redactOutboundText: (t: string) => redact(t),
237
+ assertAllowedChat: () => {},
238
+ assertSendable: () => {},
239
+ statusKey: key,
240
+ streamKey: key,
241
+ resolveReplyOwnerTurn: () => null,
242
+ findTurnByOriginId: () => null,
243
+ findTurnByQuotedMessageId: () => null,
244
+ resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
245
+ resolveThreadId: (_c: string, explicit?: string | number | null) => (explicit != null ? Number(explicit) : undefined),
246
+ getLatestInboundMessageId: () => null,
247
+ recordOutbound: () => {},
248
+ emissionAuthorityFor: () => ({
249
+ claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
250
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
251
+ finalizeCard: (fn: () => void) => fn(),
252
+ }),
253
+ clearActivitySummary: () => {},
254
+ startTypingLoop: () => {},
255
+ stopTypingLoop: () => {},
256
+ logOutbound: () => {},
257
+ closeObligationOnSubstantiveReply: () => {},
258
+ finalizeStatusReaction: () => {},
259
+ releaseTurnBufferGate: () => {},
260
+ reapQueuedStatus: () => {},
261
+ noteAgentOutputAt: () => {},
262
+ rememberAgentButtonMeta: () => {},
263
+ resolveVoiceOutPlan: () => null,
264
+ synthesizeVoiceOut: async () => null,
265
+ publishToTelegraph: async () => null,
266
+ clearSilentEndState: () => {},
267
+ emitRuntimeMetric: () => {},
268
+ shadowEmit: () => {},
269
+ progressDriver: null,
270
+ } as unknown as SendReplyGatewayDeps
271
+ return { deps, calls }
272
+ }
273
+ function req(text: string): SendReplyRequest {
274
+ return { args: { chat_id: CHAT, text }, turn: null }
275
+ }
276
+
277
+ // give the turn-flush IIFE (500ms internal setTimeout) time to reach the record
278
+ const settle = () => new Promise((r) => setTimeout(r, 650))
279
+
280
+ // ── the cases ──────────────────────────────────────────────────────────────
281
+
282
+ describe('stream-render β€” turn-flush records into the shared OutboundDedupCache', () => {
283
+ it('drives the REAL handleSessionEvent turn-flush path to outboundDedup.record', async () => {
284
+ const turn = makeTurn()
285
+ const answer = turn.capturedText.join('')
286
+ const h = makeStreamDeps({ turn })
287
+ handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
288
+ await settle()
289
+ expect(h.delivered).toContain(answer)
290
+ // the module's OWN record statement (stream-render.ts:1784) ran
291
+ expect(h.dedup.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
292
+ })
293
+ })
294
+
295
+ // ── #3237 HARD GATE: block provenance survives stream-render accumulation ───
296
+ // The whole fix hinges on `ev.lastInMessage` surviving the `case 'text'`
297
+ // accumulation into `turn.capturedBlockMeta` and reaching selectFlushDeliveryText
298
+ // via decideTurnFlush. These drive the REAL handleSessionEvent text path (not a
299
+ // preset capturedText) so the accumulation code actually runs, then assert BOTH
300
+ // the accumulated provenance AND the flush's delivered text. Each case is
301
+ // engineered so the OPPOSITE outcome would result if provenance were lost and
302
+ // the strip fell back to the opener heuristic β€” proving structure is consulted.
303
+ describe('#3237 β€” block provenance survives accumulation and reaches the flush strip', () => {
304
+ function driveTextTurn(blocks: Array<{ text: string; lastInMessage: boolean }>) {
305
+ const turn = makeTurn({ capturedText: [], capturedBlockMeta: [], answerStream: null })
306
+ const h = makeStreamDeps({ turn })
307
+ blocks.forEach((b, i) =>
308
+ handleSessionEvent(h.deps, {
309
+ kind: 'text',
310
+ text: b.text,
311
+ blockIndex: i,
312
+ lastInMessage: b.lastInMessage,
313
+ }),
314
+ )
315
+ return { turn, h }
316
+ }
317
+
318
+ it('narration FOLLOWED by a tool_use (lastInMessage=false) β†’ provenance=[true,false], flush strips the preamble', async () => {
319
+ // "Here are the figures." matches NO opener regex β€” if provenance were lost
320
+ // the opener-only strip would KEEP it and deliver the whole blob. Structure
321
+ // ([true,false]) strips it. Asserting answer-only proves structure was used.
322
+ const narration = 'Here are the figures you asked about.'
323
+ const answer = 'All three services are green.'
324
+ const { turn, h } = driveTextTurn([
325
+ { text: narration, lastInMessage: false }, // a tool_use followed it in its message
326
+ { text: answer, lastInMessage: true }, // terminal
327
+ ])
328
+ // Provenance survived the accumulation into the parallel array.
329
+ expect(turn.capturedBlockMeta).toEqual([true, false])
330
+ handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
331
+ await settle()
332
+ expect(h.delivered.join('\n')).toContain(answer)
333
+ expect(h.delivered.join('\n')).not.toContain('Here are the figures')
334
+ })
335
+
336
+ it('#3237 real answer: two paragraphs, NEITHER followed by a tool_use (lastInMessage=true) β†’ provenance=[false,false], flush keeps BOTH (no truncation)', async () => {
337
+ // p1 OPENS with "Let me explain…" β€” the opener strip WOULD drop it if
338
+ // provenance were lost. Structure ([false,false]) keeps the full answer.
339
+ const p1 = 'Let me explain the plan in two parts.'
340
+ const p2 = 'Part two: we ship it behind the existing flag.'
341
+ const { turn, h } = driveTextTurn([
342
+ { text: p1, lastInMessage: true },
343
+ { text: p2, lastInMessage: true },
344
+ ])
345
+ expect(turn.capturedBlockMeta).toEqual([false, false])
346
+ handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
347
+ await settle()
348
+ const out = h.delivered.join('\n')
349
+ expect(out).toContain('Let me explain the plan')
350
+ expect(out).toContain('Part two')
351
+ })
352
+ })
353
+
354
+ describe('cross-surface dedup β€” ONE OutboundDedupCache across P4 stream + P2 reply (Amendment 1)', () => {
355
+ it('a P4 turn-flush record suppresses the P2 same-text reply (zero wire calls)', async () => {
356
+ const dedup = new OutboundDedupCache()
357
+ const turn = makeTurn()
358
+ const answer = turn.capturedText.join('')
359
+ // P4 surface (real handleSessionEvent) records into the shared cache.
360
+ const h = makeStreamDeps({ dedup, turn })
361
+ handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
362
+ await settle()
363
+ expect(h.dedup.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
364
+
365
+ // P2 surface (real sendReply) holding the SAME instance dedups the late reply.
366
+ const s = makeSendReplyDeps(dedup)
367
+ const res = await sendReply(s.deps, req(answer))
368
+ expect(res.content[0]!.text).toBe('sent (deduped β€” same content sent via earlier path)')
369
+ expect(s.calls).toHaveLength(0)
370
+ })
371
+
372
+ it('NEGATIVE CONTROL: a SECOND cache reinstates the duplicate reply', async () => {
373
+ const streamCache = new OutboundDedupCache()
374
+ const turn = makeTurn()
375
+ const answer = turn.capturedText.join('')
376
+ const h = makeStreamDeps({ dedup: streamCache, turn })
377
+ handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
378
+ await settle()
379
+ expect(streamCache.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
380
+
381
+ // A DIFFERENT instance (the forbidden re-`new`) never saw the P4 record.
382
+ const s = makeSendReplyDeps(new OutboundDedupCache())
383
+ await sendReply(s.deps, req(answer))
384
+ expect(s.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
385
+ })
386
+ })
387
+
388
+ describe('structural β€” the singleton lives once in gateway, never in the modules (Amendment 1)', () => {
389
+ const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
390
+ const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
391
+ const sendSrc = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
392
+
393
+ it('gateway constructs EXACTLY ONE OutboundDedupCache; neither extracted module constructs any', () => {
394
+ expect(gatewaySrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(1)
395
+ expect(streamSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
396
+ expect(sendSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
397
+ })
398
+
399
+ it('both gateway deps builders inject the SAME outboundDedup binding (never a re-new)', () => {
400
+ for (const builder of ['function gatewayStreamRenderDeps(', 'function gatewaySendReplyDeps(']) {
401
+ const after = gatewaySrc.split(builder)[1] ?? ''
402
+ const body = after.split('\nfunction ')[0]?.split('\nexport ')[0] ?? after
403
+ expect(body).toMatch(/\n\s{4}outboundDedup,/)
404
+ }
405
+ })
406
+
407
+ it('handleSessionEvent is a thin wrapper delegating to the extracted module', () => {
408
+ const after = gatewaySrc.split('function handleSessionEvent(ev: SessionEvent): void {')[1] ?? ''
409
+ const body = after.split('\n}')[0] ?? after
410
+ expect(body).toContain('handleSessionEventCore(gatewayStreamRenderDeps(), ev)')
411
+ })
412
+
413
+ it('the module never reads the currentTurn global β€” only getCurrentTurn() (Amendment 9)', () => {
414
+ const fnBody = streamSrc.split('export function handleSessionEvent(')[1] ?? ''
415
+ // No bare `currentTurn` value reads survive outside comments; the live-read
416
+ // contract routes through the injected accessor.
417
+ const codeOnly = fnBody
418
+ .split('\n')
419
+ .filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
420
+ .join('\n')
421
+ expect(codeOnly).not.toMatch(/[^.\w]currentTurn\b(?!\s*[:,)])/)
422
+ expect(codeOnly).toMatch(/getCurrentTurn\(\)/)
423
+ })
424
+ })