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
@@ -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
 
@@ -101,14 +101,17 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
101
101
  expect(fn).toMatch(/hasPendingApproval\s*=\s*pendingPermissions\.size\s*>\s*0/)
102
102
  })
103
103
 
104
- it('turnInFlightMachineOnly carries BOTH busy paths (legacy claudeBusyKeys + machine)', () => {
104
+ it('turnInFlightMachineOnly reads the delivery machine (sole gate, #2996 P1)', () => {
105
105
  // #3262: the machine-in-turn signal moved here (read by the /model & /effort
106
- // resolver without the approval hold). Both the legacy claudeBusyKeys path
107
- // and the machine-authoritative probeGateParity path must be present — a
108
- // dropped branch would silently change the busy read.
106
+ // resolver without the approval hold). #2996 P1 deleted the legacy
107
+ // claudeBusyKeys branch and the gate-parity probe — the machine read is
108
+ // the ONLY busy source now; a reintroduced legacy branch would silently
109
+ // fork the busy read again.
109
110
  const fn = slice(GATEWAY, 'function turnInFlightMachineOnly()', 1400)
110
- expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0/)
111
- expect(fn).toContain('probeGateParity(')
111
+ expect(fn).toContain('return isMachineInTurn()')
112
+ // Code-shape pins (comments may still narrate the deleted legacy design):
113
+ expect(fn).not.toMatch(/claudeBusyKeys\.size/)
114
+ expect(fn).not.toMatch(/probeGateParity\(/)
112
115
  // NOT gated by the approval hold — that stays in turnInFlightForGate.
113
116
  expect(fn).not.toContain('hasPendingApproval')
114
117
  })
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Outcome pins for the photo inbound handler extracted from gateway.ts
3
+ * (switchroom#2996 P6 cluster D). Unlike the metadata-only attachment handlers,
4
+ * photo downloads the largest size from the Telegram CDN into the inbox and
5
+ * returns the on-disk path as the coalesced image. The download closure is
6
+ * driven here against a fake grammy ctx + fetch, pinning: caption-or-fallback
7
+ * envelope, largest-size selection, inbox write, and token redaction on error.
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
11
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { handlePhotoMessage, type PhotoHandlerDeps } from '../gateway/photo-message-handler.js'
15
+
16
+ const TOKEN = '123:SECRET-TOKEN'
17
+ let inboxDir: string
18
+
19
+ function makeDeps(overrides: Partial<PhotoHandlerDeps> = {}) {
20
+ const logs: string[] = []
21
+ // capture the download closure passed to handleInboundCoalesced so the test
22
+ // can invoke it and assert the returned path.
23
+ let captured: { text: string; download?: () => Promise<string | undefined> } | undefined
24
+ const handleInboundCoalesced = vi.fn(async (_ctx: any, text: string, download: any) => {
25
+ captured = { text, download }
26
+ })
27
+ const deps: PhotoHandlerDeps = {
28
+ handleInboundCoalesced,
29
+ getToken: () => TOKEN,
30
+ inboxDir,
31
+ log: line => logs.push(line),
32
+ ...overrides,
33
+ }
34
+ return { deps, handleInboundCoalesced, logs, getCaptured: () => captured }
35
+ }
36
+
37
+ function ctxWith(photo: unknown[], caption?: string, getFile?: any): any {
38
+ return {
39
+ message: { photo, caption },
40
+ api: { getFile: getFile ?? vi.fn() },
41
+ chat: { id: 42 },
42
+ }
43
+ }
44
+
45
+ beforeEach(() => {
46
+ inboxDir = mkdtempSync(join(tmpdir(), 'photo-inbox-'))
47
+ })
48
+ afterEach(() => {
49
+ rmSync(inboxDir, { recursive: true, force: true })
50
+ vi.restoreAllMocks()
51
+ })
52
+
53
+ describe('handlePhotoMessage', () => {
54
+ it('uses the caption as the envelope, falling back to (photo)', async () => {
55
+ const { deps, handleInboundCoalesced } = makeDeps()
56
+ await handlePhotoMessage(ctxWith([{ file_id: 'p1', file_unique_id: 'u1' }]), deps)
57
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('(photo)')
58
+
59
+ const { deps: deps2, handleInboundCoalesced: h2 } = makeDeps()
60
+ await handlePhotoMessage(ctxWith([{ file_id: 'p1', file_unique_id: 'u1' }], 'look'), deps2)
61
+ expect(h2.mock.calls[0][1]).toBe('look')
62
+ })
63
+
64
+ it('downloads the largest size and writes it into the inbox', async () => {
65
+ const getFile = vi.fn(async () => ({ file_path: 'photos/big.jpg' }))
66
+ const ctx = ctxWith(
67
+ [{ file_id: 'small', file_unique_id: 'us' }, { file_id: 'big', file_unique_id: 'ub' }],
68
+ undefined,
69
+ getFile,
70
+ )
71
+ const { deps, getCaptured } = makeDeps()
72
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
73
+ new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
74
+ )
75
+ await handlePhotoMessage(ctx, deps)
76
+ const dlPath = await getCaptured()!.download!()
77
+ // Largest (last) size selected.
78
+ expect(getFile).toHaveBeenCalledWith('big')
79
+ // Token embedded in the CDN URL.
80
+ expect(fetchSpy.mock.calls[0][0]).toContain(`/bot${TOKEN}/photos/big.jpg`)
81
+ expect(dlPath).toBeDefined()
82
+ expect(existsSync(dlPath!)).toBe(true)
83
+ expect(Array.from(readFileSync(dlPath!))).toEqual([1, 2, 3])
84
+ })
85
+
86
+ it('returns undefined and logs HTTP status on a failed download', async () => {
87
+ const getFile = vi.fn(async () => ({ file_path: 'photos/x.jpg' }))
88
+ const { deps, getCaptured, logs } = makeDeps()
89
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 502 }))
90
+ await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
91
+ const dlPath = await getCaptured()!.download!()
92
+ expect(dlPath).toBeUndefined()
93
+ expect(logs.some(l => l.includes('HTTP 502'))).toBe(true)
94
+ })
95
+
96
+ it('redacts the bot token from a thrown error message', async () => {
97
+ const getFile = vi.fn(async () => ({ file_path: 'photos/x.jpg' }))
98
+ const { deps, getCaptured, logs } = makeDeps()
99
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error(`connect failed to bot${TOKEN}/x`))
100
+ await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
101
+ const dlPath = await getCaptured()!.download!()
102
+ expect(dlPath).toBeUndefined()
103
+ const line = logs.find(l => l.startsWith('telegram gateway: photo download failed:'))!
104
+ expect(line).toContain('<REDACTED>')
105
+ expect(line).not.toContain(TOKEN)
106
+ })
107
+
108
+ it('returns undefined when the file has no path', async () => {
109
+ const getFile = vi.fn(async () => ({}))
110
+ const { deps, getCaptured } = makeDeps()
111
+ await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
112
+ expect(await getCaptured()!.download!()).toBeUndefined()
113
+ })
114
+ })
@@ -21,12 +21,15 @@ import { describe, it, expect } from 'vitest'
21
21
  import { readFileSync } from 'node:fs'
22
22
  import { resolve } from 'node:path'
23
23
 
24
+ // #2996 P2: executeReply's body (incl. the file-send + result + history
25
+ // block this suite pins) moved VERBATIM to outbound-send-path.ts
26
+ // (`sendReply`); the slice reads there now. Assertions unchanged.
24
27
  const gatewaySrc = readFileSync(
25
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
28
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
26
29
  'utf-8',
27
30
  )
28
31
 
29
- // Slice executeReply's file-send + result + history block: from the
32
+ // Slice sendReply's file-send + result + history block: from the
30
33
  // photo-precheck anchor to the recordOutbound call for replies.
31
34
  const sliceStart = gatewaySrc.indexOf('const photoPrecheck = new Map')
32
35
  const sliceEnd = gatewaySrc.indexOf('history recordOutbound (reply) failed')
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Outcome pins for the `message:pinned_message` service-message cleanup handler
3
+ * extracted from gateway.ts (switchroom#2996 P6 cluster F). Asserts the
4
+ * chat-scoped ownership guard (only OUR pins get their service message
5
+ * deleted), the reconcile-store race retry, and best-effort logging on a
6
+ * delete failure — against injected pin-state maps + a mock deleteServiceMessage.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import {
11
+ handlePinnedMessage,
12
+ type PinnedMessageHandlerDeps,
13
+ } from '../gateway/pinned-message-handler.js'
14
+
15
+ function makeDeps(over: Partial<PinnedMessageHandlerDeps> = {}) {
16
+ const deleteServiceMessage = vi.fn(async () => {})
17
+ const log = vi.fn()
18
+ const deps: PinnedMessageHandlerDeps = {
19
+ statusPinState: new Map(),
20
+ statusPinChatIds: new Map(),
21
+ deleteServiceMessage,
22
+ log,
23
+ ...over,
24
+ }
25
+ return { deps, deleteServiceMessage, log }
26
+ }
27
+
28
+ function ctxWith(chatId: number, pinnedMessageId: number | undefined, serviceMsgId = 999): any {
29
+ return {
30
+ msg: {
31
+ message_id: serviceMsgId,
32
+ pinned_message: pinnedMessageId == null ? undefined : { message_id: pinnedMessageId },
33
+ },
34
+ chat: { id: chatId },
35
+ }
36
+ }
37
+
38
+ describe('handlePinnedMessage — ownership guard', () => {
39
+ it('deletes the service message when the pin is ours in this chat', async () => {
40
+ const { deps, deleteServiceMessage } = makeDeps({
41
+ statusPinState: new Map([['fg:a', { messageId: 555 }]]),
42
+ statusPinChatIds: new Map([['fg:a', '42']]),
43
+ })
44
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
45
+ expect(deleteServiceMessage).toHaveBeenCalledWith('42', 999)
46
+ })
47
+
48
+ it('does NOT delete when the pinned id matches but the tracked chat differs', async () => {
49
+ const { deps, deleteServiceMessage } = makeDeps({
50
+ statusPinState: new Map([['fg:a', { messageId: 555 }]]),
51
+ statusPinChatIds: new Map([['fg:a', '99']]), // tracked in a DIFFERENT chat
52
+ })
53
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
54
+ expect(deleteServiceMessage).not.toHaveBeenCalled()
55
+ })
56
+
57
+ it('does nothing when there is no pinned_message id', async () => {
58
+ const { deps, deleteServiceMessage } = makeDeps()
59
+ await handlePinnedMessage(ctxWith(42, undefined), deps)
60
+ expect(deleteServiceMessage).not.toHaveBeenCalled()
61
+ })
62
+
63
+ it('does not delete an untracked (operator-manual) pin', async () => {
64
+ const { deps, deleteServiceMessage } = makeDeps()
65
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
66
+ expect(deleteServiceMessage).not.toHaveBeenCalled()
67
+ })
68
+ })
69
+
70
+ describe('handlePinnedMessage — reconcile-store race', () => {
71
+ it('re-checks ownership once after the store catches up', async () => {
72
+ // REAL timers on purpose: this suite also runs under `bun test`, whose
73
+ // vitest shim does not support vi.useFakeTimers — fake timers here left
74
+ // the global clock mocked and hung the NEXT test file in CI (the exact
75
+ // #3354 bun-test failure). The handler waits 250ms before its single
76
+ // re-check; populate the store inside that window and await the real
77
+ // delay.
78
+ const state = new Map<string, { messageId: number }>()
79
+ const chatIds = new Map<string, string>()
80
+ const { deps, deleteServiceMessage } = makeDeps({
81
+ statusPinState: state,
82
+ statusPinChatIds: chatIds,
83
+ })
84
+ const p = handlePinnedMessage(ctxWith(42, 555, 999), deps)
85
+ // Store catches up during the 250ms race window.
86
+ state.set('fg:a', { messageId: 555 })
87
+ chatIds.set('fg:a', '42')
88
+ await p
89
+ expect(deleteServiceMessage).toHaveBeenCalledWith('42', 999)
90
+ })
91
+ })
92
+
93
+ describe('handlePinnedMessage — best-effort delete failure', () => {
94
+ it('logs a concise reason when the delete throws', async () => {
95
+ const { deps, log } = makeDeps({
96
+ statusPinState: new Map([['fg:a', { messageId: 555 }]]),
97
+ statusPinChatIds: new Map([['fg:a', '42']]),
98
+ deleteServiceMessage: vi.fn(async () => {
99
+ throw new Error('not enough rights')
100
+ }),
101
+ })
102
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
103
+ expect(log).toHaveBeenCalledTimes(1)
104
+ const line = log.mock.calls[0][0] as string
105
+ expect(line).toContain('could not delete pin service message')
106
+ expect(line).toContain('not enough rights')
107
+ })
108
+ })
@@ -143,6 +143,48 @@ describe("render: inline palette", () => {
143
143
  });
144
144
  });
145
145
 
146
+ describe("render: line-leading issue refs are not headings (#3304 regression)", () => {
147
+ it("multi-line message whose first line starts with #3304 stays a paragraph", () => {
148
+ const md = "#3304 is merged. The regression suite is green.\n\nNext up: the follow-up PR.";
149
+ const doc = parse(md);
150
+ // Spec-correct parse: `#` without a following space is NOT an ATX heading.
151
+ expect(doc.blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]);
152
+
153
+ const out = render(doc);
154
+ // The line-leading `#` must be escaped so Telegram's non-spec rich parser
155
+ // cannot promote the line to a heading...
156
+ expect(out.startsWith("\\#3304 is merged.")).toBe(true);
157
+ expect(out).not.toMatch(/^#{1,6} /m);
158
+ // ...and the escape resolves back to the literal "#3304" on re-parse
159
+ // (what the reader sees), with no heading node anywhere.
160
+ const reparsed = parse(out);
161
+ expect(reparsed.blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]);
162
+ expect((reparsed.blocks[0] as any).children[0].text).toContain("#3304 is merged.");
163
+ });
164
+
165
+ it("issue ref at line start mid-message is escaped too", () => {
166
+ const md = "Shipped today:\n#123 closes the loop on retries.";
167
+ const out = render(parse(md));
168
+ expect(out).toContain("\n\\#123 closes the loop");
169
+ const reparsed = parse(out);
170
+ expect(reparsed.blocks.every((b) => b.type !== "heading")).toBe(true);
171
+ });
172
+
173
+ it("a genuine `# Heading` still renders as a heading", () => {
174
+ const md = "# Release notes\n\n#456 fixed the flake.";
175
+ const out = render(parse(md));
176
+ expect(out.startsWith("# Release notes")).toBe(true);
177
+ expect(out).toContain("\\#456 fixed the flake.");
178
+ const reparsed = parse(out);
179
+ expect(reparsed.blocks[0].type).toBe("heading");
180
+ });
181
+
182
+ it("mid-line `#` is left alone", () => {
183
+ const md = "merged in PR #789 yesterday";
184
+ expect(render(parse(md))).toBe(md);
185
+ });
186
+ });
187
+
146
188
  describe("render: block palette", () => {
147
189
  it("heading levels 1-6", () => {
148
190
  for (let level = 1; level <= 6; level++) {
@@ -43,8 +43,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
43
43
  const fs = require('node:fs') as typeof import('node:fs')
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const path = require('node:path') as typeof import('node:path')
46
+ // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
47
+ // (`sendReply`); the post-send block anchors live there now.
46
48
  const src = fs.readFileSync(
47
- path.resolve(__dirname, '../gateway/gateway.ts'),
49
+ path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
48
50
  'utf8',
49
51
  )
50
52
  // Find the executeReply post-send block — anchored on the
@@ -75,8 +77,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
75
77
  const fs = require('node:fs') as typeof import('node:fs')
76
78
  // eslint-disable-next-line @typescript-eslint/no-require-imports
77
79
  const path = require('node:path') as typeof import('node:path')
80
+ // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
81
+ // (`sendReply`); the post-send block anchors live there now.
78
82
  const src = fs.readFileSync(
79
- path.resolve(__dirname, '../gateway/gateway.ts'),
83
+ path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
80
84
  'utf8',
81
85
  )
82
86
  const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
@@ -31,10 +31,14 @@ import { describe, it, expect } from "vitest";
31
31
  import { readFileSync } from "node:fs";
32
32
  import { resolve } from "node:path";
33
33
 
34
- const gatewaySrc = readFileSync(
35
- resolve(__dirname, "..", "gateway", "gateway.ts"),
36
- "utf-8",
37
- );
34
+ const gatewaySrc =
35
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
36
+ "\n" +
37
+ // P7 PR-9 (#2996): the secret-detect pipeline intercept moved verbatim into
38
+ // gateway/inbound-interceptors.ts (interceptSecretDetectPipeline); the
39
+ // FAIL-CLOSED block anchor now resolves there. deleteSensitiveMessage is
40
+ // injected via deps but every branch still routes through it.
41
+ readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
38
42
 
39
43
  /** Slice the gateway source between two anchor strings. */
40
44
  function sliceBetween(src: string, from: string, to: string): string {
@@ -19,10 +19,20 @@ import { readFileSync } from 'node:fs'
19
19
  * bytes directly.
20
20
  */
21
21
  describe('secret-detect pipeline fail-closed contract (gateway)', () => {
22
- const src = readFileSync(
22
+ // P7 PR-9 (#2996): the pipeline intercept (incl. the fail-closed catch)
23
+ // moved verbatim into gateway/inbound-interceptors.ts
24
+ // (interceptSecretDetectPipeline). The catch now returns `{ handled: true }`
25
+ // and the gateway call site early-returns on `handled` BEFORE the sinks —
26
+ // the contract is unchanged, split across the seam.
27
+ const gatewaySrc = readFileSync(
23
28
  new URL('../gateway/gateway.ts', import.meta.url),
24
29
  'utf8',
25
30
  )
31
+ const interceptorsSrc = readFileSync(
32
+ new URL('../gateway/inbound-interceptors.ts', import.meta.url),
33
+ 'utf8',
34
+ )
35
+ const src = gatewaySrc + '\n' + interceptorsSrc
26
36
 
27
37
  it('has exactly one [secret-detect] pipeline error catch marker', () => {
28
38
  const matches = src.match(/\[secret-detect\] pipeline error/g) ?? []
@@ -30,25 +40,23 @@ describe('secret-detect pipeline fail-closed contract (gateway)', () => {
30
40
  })
31
41
 
32
42
  it('the catch block around runPipeline returns (does not fall through to recordInbound / broadcast)', () => {
33
- const idx = src.indexOf('[secret-detect] pipeline error')
43
+ // Interceptor side: after the error marker, the catch must return
44
+ // `{ handled: true }` (drop) — never fall out of the catch body.
45
+ const idx = interceptorsSrc.indexOf('[secret-detect] pipeline error')
34
46
  expect(idx).toBeGreaterThan(0)
35
-
36
- // The fail-closed `return` must appear BEFORE the next recordInbound()
37
- // and ipcServer.broadcast() calls. We scan forward from the error-log
38
- // marker for the first top-level `return` statement (indented by 4
39
- // spaces, matching the handleInbound body style).
40
- const tail = src.slice(idx)
41
- const returnMatch = tail.match(/\n {4}return\b/)
42
- expect(returnMatch).not.toBeNull()
43
- const returnOffset = returnMatch!.index!
44
- const recordOffset = tail.indexOf('recordInbound(')
45
- const broadcastOffset = tail.indexOf('ipcServer.broadcast(inboundMsg)')
46
- // Both sinks are further down the function body; the fail-closed
47
- // return must come first.
48
- expect(recordOffset).toBeGreaterThan(0)
49
- expect(broadcastOffset).toBeGreaterThan(0)
50
- expect(returnOffset).toBeLessThan(recordOffset)
51
- expect(returnOffset).toBeLessThan(broadcastOffset)
47
+ const tail = interceptorsSrc.slice(idx, idx + 2000)
48
+ expect(tail).toMatch(/\n\s*return \{ handled: true \}/)
49
+ // Gateway side: the delegation early-returns on `handled` BEFORE both
50
+ // sinks, so a dropped message can never reach them.
51
+ const callIdx = gatewaySrc.indexOf('await interceptSecretDetectPipeline(')
52
+ expect(callIdx).toBeGreaterThan(0)
53
+ const afterCall = gatewaySrc.slice(callIdx)
54
+ const handledReturn = afterCall.indexOf('if (secretOutcome.handled) return')
55
+ const recordOffset = afterCall.indexOf('recordInbound(')
56
+ const broadcastOffset = afterCall.indexOf('ipcServer.broadcast(inboundMsg)')
57
+ expect(handledReturn).toBeGreaterThan(0)
58
+ expect(recordOffset).toBeGreaterThan(handledReturn)
59
+ expect(broadcastOffset).toBeGreaterThan(handledReturn)
52
60
  })
53
61
 
54
62
  it('catch body warns the user that the message was dropped', () => {
@@ -58,15 +66,17 @@ describe('secret-detect pipeline fail-closed contract (gateway)', () => {
58
66
  })
59
67
 
60
68
  it('no path exists where recordInbound or broadcast is called with the raw effectiveText after a pipeline throw', () => {
61
- // Structural guarantee: between the catch marker and the next sink
62
- // there must be a `return` — otherwise a thrown pipeline error could
63
- // flow through.
64
- const catchIdx = src.indexOf('[secret-detect] pipeline error')
65
- const afterCatch = src.slice(catchIdx)
66
- const recordIdx = afterCatch.indexOf('recordInbound(')
67
- expect(recordIdx).toBeGreaterThan(0)
68
- const gap = afterCatch.slice(0, recordIdx)
69
- expect(gap).toMatch(/\n\s*return\b/)
69
+ // Structural guarantee across the seam: the interceptor catch returns
70
+ // handled:true, and the gateway adopts the scrubbed effectiveText only
71
+ // on the handled:false path — there is no branch that reaches the sinks
72
+ // after a throw. Pin: the catch body ends in `return { handled: true }`
73
+ // with no intervening effectiveText pass-through.
74
+ const catchIdx = interceptorsSrc.indexOf('[secret-detect] pipeline error')
75
+ expect(catchIdx).toBeGreaterThan(0)
76
+ const catchTail = interceptorsSrc.slice(catchIdx, catchIdx + 2000)
77
+ const returnIdx = catchTail.search(/\n\s*return \{ handled: true \}/)
78
+ expect(returnIdx).toBeGreaterThan(0)
79
+ expect(catchTail.slice(0, returnIdx)).not.toMatch(/handled: false/)
70
80
  })
71
81
 
72
82
  it('server.ts no longer contains the secret-detect intercept', () => {