switchroom 0.18.32 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Outcome pins for the media-envelope inbound handlers extracted from
3
+ * gateway.ts (switchroom#2996 P6 cluster A). Each handler is driven with a
4
+ * fake grammy ctx + mock dispatch deps; the assertions pin the EXACT text
5
+ * envelope forwarded to `handleInbound` (and the stderr log line, and the
6
+ * ack/refuse dispatch) — the behaviour the gateway previously produced inline.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import type { Context } from 'grammy'
11
+ import {
12
+ safeName,
13
+ PASSPORT_REFUSAL_TEXT,
14
+ handleContactMessage,
15
+ handleLocationMessage,
16
+ handleVenueMessage,
17
+ handlePollMessage,
18
+ handleWebAppDataMessage,
19
+ handleUsersSharedMessage,
20
+ handleChatSharedMessage,
21
+ handleSuccessfulPaymentMessage,
22
+ handlePassportDataMessage,
23
+ handleDiceMessage,
24
+ handleGameMessage,
25
+ handleStoryMessage,
26
+ handlePaidMediaMessage,
27
+ type MediaEnvelopeDeps,
28
+ } from '../gateway/media-message-handlers.js'
29
+
30
+ function makeDeps() {
31
+ const handleInbound = vi.fn(async () => {})
32
+ const handleAckOnly = vi.fn(async () => {})
33
+ const handleRefusal = vi.fn(async () => {})
34
+ const logs: string[] = []
35
+ const deps: MediaEnvelopeDeps = {
36
+ handleInbound,
37
+ handleAckOnly,
38
+ handleRefusal,
39
+ log: line => logs.push(line),
40
+ }
41
+ return { deps, handleInbound, handleAckOnly, handleRefusal, logs }
42
+ }
43
+
44
+ // Build a fake ctx whose `message` carries the given content key + a chat id.
45
+ function ctxWith(content: Record<string, unknown>, chatId: number | null = 42): any {
46
+ return {
47
+ message: content,
48
+ chat: chatId == null ? undefined : { id: chatId },
49
+ }
50
+ }
51
+
52
+ describe('safeName', () => {
53
+ it('strips framing/injection characters', () => {
54
+ expect(safeName('a<b>[c]\r\nd;e')).toBe('a_b__c___d_e')
55
+ })
56
+ it('passes undefined through', () => {
57
+ expect(safeName(undefined)).toBeUndefined()
58
+ })
59
+ })
60
+
61
+ describe('handleContactMessage', () => {
62
+ it('renders the contact envelope and forwards it', async () => {
63
+ const { deps, handleInbound, logs } = makeDeps()
64
+ const ctx = ctxWith({
65
+ contact: { phone_number: '+15551234', first_name: 'Ada', last_name: 'Love', user_id: 7 },
66
+ })
67
+ await handleContactMessage(ctx, deps)
68
+ expect(handleInbound).toHaveBeenCalledWith(
69
+ ctx,
70
+ '(contact: name="Ada Love" phone="+15551234" user_id=7)',
71
+ undefined,
72
+ )
73
+ expect(logs[0]).toBe('telegram gateway: inbound contact from chat=42\n')
74
+ })
75
+
76
+ it('falls back to ? for missing fields and omits user_id', async () => {
77
+ const { deps, handleInbound } = makeDeps()
78
+ const ctx = ctxWith({ contact: {} })
79
+ await handleContactMessage(ctx, deps)
80
+ expect(handleInbound).toHaveBeenCalledWith(ctx, '(contact: name="?" phone="?")', undefined)
81
+ })
82
+
83
+ it('logs and swallows a builder error without forwarding', async () => {
84
+ const { deps, handleInbound, logs } = makeDeps()
85
+ // ctx.message is null → property access throws inside try.
86
+ const ctx = { message: null, chat: { id: 42 } } as unknown as Context
87
+ await handleContactMessage(ctx as any, deps)
88
+ expect(handleInbound).not.toHaveBeenCalled()
89
+ expect(logs.some(l => l.startsWith('telegram gateway: contact handler error:'))).toBe(true)
90
+ })
91
+ })
92
+
93
+ describe('handleLocationMessage', () => {
94
+ it('renders lat/lon at 6dp with live_period', async () => {
95
+ const { deps, handleInbound } = makeDeps()
96
+ const ctx = ctxWith({ location: { latitude: 12.3, longitude: -45.6, live_period: 60 } })
97
+ await handleLocationMessage(ctx, deps)
98
+ expect(handleInbound).toHaveBeenCalledWith(
99
+ ctx,
100
+ '(location: lat=12.300000 lon=-45.600000 live_period=60s)',
101
+ undefined,
102
+ )
103
+ })
104
+ it('omits live_period when absent and ? for non-numeric coords', async () => {
105
+ const { deps, handleInbound } = makeDeps()
106
+ const ctx = ctxWith({ location: {} })
107
+ await handleLocationMessage(ctx, deps)
108
+ expect(handleInbound).toHaveBeenCalledWith(ctx, '(location: lat=? lon=?)', undefined)
109
+ })
110
+ })
111
+
112
+ describe('handleVenueMessage', () => {
113
+ it('renders the venue envelope', async () => {
114
+ const { deps, handleInbound } = makeDeps()
115
+ const ctx = ctxWith({
116
+ venue: { title: 'Cafe', address: '1 St', location: { latitude: 1, longitude: 2 } },
117
+ })
118
+ await handleVenueMessage(ctx, deps)
119
+ expect(handleInbound).toHaveBeenCalledWith(
120
+ ctx,
121
+ '(venue: title="Cafe" address="1 St" lat=1.000000 lon=2.000000)',
122
+ undefined,
123
+ )
124
+ })
125
+ })
126
+
127
+ describe('handlePollMessage', () => {
128
+ it('renders question, count, anonymity and first-10 choices', async () => {
129
+ const { deps, handleInbound } = makeDeps()
130
+ const ctx = ctxWith({
131
+ poll: {
132
+ question: 'Pick',
133
+ is_anonymous: true,
134
+ options: [{ text: 'A' }, { text: 'B' }],
135
+ },
136
+ })
137
+ await handlePollMessage(ctx, deps)
138
+ expect(handleInbound).toHaveBeenCalledWith(
139
+ ctx,
140
+ '(poll: question="Pick" options=2 anonymous choices=[A | B])',
141
+ undefined,
142
+ )
143
+ })
144
+ })
145
+
146
+ describe('handleWebAppDataMessage', () => {
147
+ it('renders JSON-encoded data and logs byte count', async () => {
148
+ const { deps, handleInbound, logs } = makeDeps()
149
+ const ctx = ctxWith({ web_app_data: { data: 'hi', button_text: 'Go' } })
150
+ await handleWebAppDataMessage(ctx, deps)
151
+ expect(handleInbound).toHaveBeenCalledWith(
152
+ ctx,
153
+ '(web_app_data: button="Go" data="hi")',
154
+ undefined,
155
+ )
156
+ expect(logs[0]).toContain('button="Go" bytes=2')
157
+ })
158
+ it('truncates oversized data at 4096 chars', async () => {
159
+ const { deps, handleInbound } = makeDeps()
160
+ const big = 'x'.repeat(5000)
161
+ const ctx = ctxWith({ web_app_data: { data: big, button_text: 'B' } })
162
+ await handleWebAppDataMessage(ctx, deps)
163
+ const arg = (handleInbound as any).mock.calls[0][1] as string
164
+ expect(arg).toContain('…(truncated)')
165
+ expect(arg).toContain(JSON.stringify('x'.repeat(4096) + '…(truncated)'))
166
+ })
167
+ })
168
+
169
+ describe('handleUsersSharedMessage', () => {
170
+ it('renders shared user ids and count', async () => {
171
+ const { deps, handleInbound } = makeDeps()
172
+ const ctx = ctxWith({ users_shared: { request_id: 9, users: [{ user_id: 1 }, { user_id: 2 }] } })
173
+ await handleUsersSharedMessage(ctx, deps)
174
+ expect(handleInbound).toHaveBeenCalledWith(
175
+ ctx,
176
+ '(users_shared: request_id=9 user_ids=[1,2] count=2)',
177
+ undefined,
178
+ )
179
+ })
180
+ })
181
+
182
+ describe('handleChatSharedMessage', () => {
183
+ it('renders shared chat with optional title', async () => {
184
+ const { deps, handleInbound } = makeDeps()
185
+ const ctx = ctxWith({ chat_shared: { request_id: 3, chat_id: -100, title: 'Grp' } })
186
+ await handleChatSharedMessage(ctx, deps)
187
+ expect(handleInbound).toHaveBeenCalledWith(
188
+ ctx,
189
+ '(chat_shared: request_id=3 chat_id=-100 title="Grp")',
190
+ undefined,
191
+ )
192
+ })
193
+ it('omits title when absent', async () => {
194
+ const { deps, handleInbound } = makeDeps()
195
+ const ctx = ctxWith({ chat_shared: { request_id: 3, chat_id: -100 } })
196
+ await handleChatSharedMessage(ctx, deps)
197
+ expect(handleInbound).toHaveBeenCalledWith(
198
+ ctx,
199
+ '(chat_shared: request_id=3 chat_id=-100)',
200
+ undefined,
201
+ )
202
+ })
203
+ })
204
+
205
+ describe('handleSuccessfulPaymentMessage', () => {
206
+ it('logs structured fields and ack-only-warns, never forwarding', async () => {
207
+ const { deps, handleInbound, handleAckOnly, logs } = makeDeps()
208
+ const ctx = ctxWith({
209
+ successful_payment: {
210
+ currency: 'USD',
211
+ total_amount: 500,
212
+ invoice_payload: 'order-1',
213
+ provider_payment_charge_id: 'pc',
214
+ telegram_payment_charge_id: 'tc',
215
+ },
216
+ })
217
+ await handleSuccessfulPaymentMessage(ctx, deps)
218
+ expect(handleInbound).not.toHaveBeenCalled()
219
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'successful_payment', { warn: true })
220
+ expect(logs[0]).toContain('currency=USD total_amount=500')
221
+ expect(logs[0]).toContain('payload="order-1"')
222
+ })
223
+ })
224
+
225
+ describe('ack-only leaves (dice/game/story/paid_media)', () => {
226
+ it('handleDiceMessage logs and reacts 🎲, never forwarding', async () => {
227
+ const { deps, handleInbound, handleAckOnly, logs } = makeDeps()
228
+ const ctx = ctxWith({ dice: { emoji: '🎲', value: 4 } })
229
+ await handleDiceMessage(ctx, deps)
230
+ expect(handleInbound).not.toHaveBeenCalled()
231
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'dice', { emoji: '🎲' })
232
+ expect(logs[0]).toBe('telegram gateway: inbound dice from chat=42\n')
233
+ })
234
+
235
+ it('handleGameMessage logs and acks with no options', async () => {
236
+ const { deps, handleAckOnly, logs } = makeDeps()
237
+ const ctx = ctxWith({ game: {} })
238
+ await handleGameMessage(ctx, deps)
239
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'game')
240
+ expect(logs[0]).toBe('telegram gateway: inbound game from chat=42\n')
241
+ })
242
+
243
+ it('handleStoryMessage logs and acks with no options', async () => {
244
+ const { deps, handleAckOnly, logs } = makeDeps()
245
+ const ctx = ctxWith({ story: {} })
246
+ await handleStoryMessage(ctx, deps)
247
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'story')
248
+ expect(logs[0]).toBe('telegram gateway: inbound story from chat=42\n')
249
+ })
250
+
251
+ it('handlePaidMediaMessage logs and acks warn:true', async () => {
252
+ const { deps, handleInbound, handleAckOnly, logs } = makeDeps()
253
+ const ctx = ctxWith({ paid_media: {} })
254
+ await handlePaidMediaMessage(ctx, deps)
255
+ expect(handleInbound).not.toHaveBeenCalled()
256
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'paid_media', { warn: true })
257
+ expect(logs[0]).toBe('telegram gateway: inbound paid_media from chat=42\n')
258
+ })
259
+
260
+ it('falls back to ? for a missing chat id', async () => {
261
+ const { deps, logs } = makeDeps()
262
+ const ctx = ctxWith({ dice: {} }, null)
263
+ await handleDiceMessage(ctx, deps)
264
+ expect(logs[0]).toBe('telegram gateway: inbound dice from chat=?\n')
265
+ })
266
+ })
267
+
268
+ describe('handlePassportDataMessage', () => {
269
+ it('refuses with the fixed message and never forwards', async () => {
270
+ const { deps, handleInbound, handleRefusal } = makeDeps()
271
+ const ctx = ctxWith({ passport_data: {} })
272
+ await handlePassportDataMessage(ctx, deps)
273
+ expect(handleInbound).not.toHaveBeenCalled()
274
+ expect(handleRefusal).toHaveBeenCalledWith(ctx, 'passport_data', PASSPORT_REFUSAL_TEXT)
275
+ })
276
+ })
@@ -20,13 +20,17 @@ import { readFileSync } from 'node:fs'
20
20
  import { resolve } from 'node:path'
21
21
 
22
22
  // #2996 Phase 5: the callback-query handler families moved verbatim to
23
- // gateway/callback-query-handlers.ts; these pins read the gateway source
24
- // COMBINED with that module so the wiring assertions keep covering the
25
- // same runtime source text.
23
+ // gateway/callback-query-handlers.ts, and the executeMentalModelPropose tool
24
+ // handler to gateway/card-tool-handlers.ts (P5-tail); these pins read the
25
+ // gateway source COMBINED with those modules so the wiring assertions keep
26
+ // covering the same runtime source text. (The MENTAL_MODEL_* cap constant
27
+ // DEFINITIONS stay in gateway.ts and are injected.)
26
28
  const gatewaySrc =
27
29
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
28
30
  '\n' +
29
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
31
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
32
+ '\n' +
33
+ readFileSync(resolve(__dirname, '..', 'gateway', 'card-tool-handlers.ts'), 'utf-8')
30
34
 
31
35
  function proposeCallbackBlock(): string {
32
36
  return (
@@ -444,6 +444,36 @@ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
444
444
  it("every alias target is a sr-* id", () => {
445
445
  for (const target of Object.values(SR_MODEL_ALIASES)) expect(isSrModel(target)).toBe(true);
446
446
  });
447
+ it("2026-07-19 grok/kimi/gpt flagship buttons expand to their sr-* ids", () => {
448
+ expect(expandSrAlias("grok")).toBe("sr-grok-4.5");
449
+ expect(expandSrAlias("kimi")).toBe("sr-kimi-k3");
450
+ expect(expandSrAlias("gpt")).toBe("sr-gpt-5.6-sol");
451
+ });
452
+ it("per-tier GPT-5.6 buttons sol/terra/luna expand to their sr-* ids", () => {
453
+ expect(expandSrAlias("sol")).toBe("sr-gpt-5.6-sol");
454
+ expect(expandSrAlias("terra")).toBe("sr-gpt-5.6-terra");
455
+ expect(expandSrAlias("luna")).toBe("sr-gpt-5.6-luna");
456
+ for (const a of ["sol", "terra", "luna"]) expect(isOfflineTrustedModelToken(a)).toBe(true);
457
+ });
458
+ it("new flagship buttons are offline-trustable carriers (#3042 gate)", () => {
459
+ for (const a of ["grok", "kimi", "gpt"]) expect(isOfflineTrustedModelToken(a)).toBe(true);
460
+ for (const id of ["sr-grok-4.5", "sr-kimi-k3", "sr-gpt-5.6-sol"])
461
+ expect(isOfflineTrustedModelToken(id)).toBe(true);
462
+ });
463
+ it("labels every new grok/kimi/gpt-5.6 sr-* id (typeable tiers included)", () => {
464
+ for (const id of [
465
+ "sr-grok-4.5",
466
+ "sr-grok-4.3",
467
+ "sr-kimi-k3",
468
+ "sr-kimi-k2.7-code",
469
+ "sr-gpt-5.6-sol",
470
+ "sr-gpt-5.6-terra",
471
+ "sr-gpt-5.6-luna",
472
+ ]) {
473
+ expect(SR_MODEL_LABELS[id]).toBeTypeOf("string");
474
+ expect(isValidModelArg(id)).toBe(true);
475
+ }
476
+ });
447
477
  });
448
478
 
449
479
  describe("classifyDiscoveredOptions", () => {
@@ -19,10 +19,26 @@ const sendPathSrc = readFileSync(
19
19
  'utf-8',
20
20
  )
21
21
  const sendReplyFn = sendPathSrc.split('export async function sendReply(')[1]?.split('\nexport ')[0] ?? ''
22
- const gatewaySrc = readFileSync(
23
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
22
+ const gatewaySrc =
23
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
24
+ '\n' +
25
+ // P7 PR-10 (#2996): the InboundMessage envelope assembly (origin_turn_id,
26
+ // topic_scope, meta lane) moved verbatim into gateway/inbound-router.ts
27
+ // (buildInboundEnvelope) — include it in the scraped corpus.
28
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-router.ts'), 'utf-8')
29
+ // #2996 P4-A: the enqueue handler (turn ctor: `const turnId = deriveTurnId`,
30
+ // `rememberRecentTurn(next)`, `promoteQueuedStatus`) moved VERBATIM into
31
+ // stream-render.ts with handleSessionEvent. Enqueue-seam assertions span both.
32
+ // #2996 P8 PR-B: the turn-end funnel bodies moved verbatim to turn-end.ts.
33
+ const turnEndSrc = readFileSync(
34
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
24
35
  'utf-8',
25
36
  )
37
+ const streamSrc = readFileSync(
38
+ resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
39
+ 'utf-8',
40
+ )
41
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
26
42
  const bridgeSrc = readFileSync(
27
43
  resolve(__dirname, '..', 'bridge', 'bridge.ts'),
28
44
  'utf-8',
@@ -38,12 +54,12 @@ const deriveTurnIdSrc = readFileSync(
38
54
  describe('component 3 — turn-origin reply routing', () => {
39
55
  it('CurrentTurn carries a turnId, and the enqueue handler initialises it', () => {
40
56
  expect(gatewaySrc).toMatch(/turnId: string/)
41
- expect(gatewaySrc).toMatch(/const turnId\s*=\s*\n?\s*deriveTurnId\(/)
42
- expect(gatewaySrc).toMatch(/rememberRecentTurn\(next\)/)
57
+ expect(gatewayAndStreamSrc).toMatch(/const turnId\s*=\s*\n?\s*deriveTurnId\(/)
58
+ expect(gatewayAndStreamSrc).toMatch(/rememberRecentTurn\(next\)/)
43
59
  })
44
60
 
45
61
  it('the inbound meta stamps origin_turn_id derived from chat/thread/messageId', () => {
46
- expect(gatewaySrc).toMatch(/const originTurnId = deriveTurnId\(chat_id, messageThreadId \?\? null, msgId\)/)
62
+ expect(gatewaySrc).toMatch(/const originTurnId = deriveTurnId\((?:p\.)?chat_id, (?:p\.)?messageThreadId \?\? null, (?:p\.)?msgId\)/)
47
63
  expect(gatewaySrc).toMatch(/origin_turn_id: originTurnId/)
48
64
  })
49
65
 
@@ -129,7 +145,9 @@ describe('component 4 — per-turn topic framing', () => {
129
145
  expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED/)
130
146
  expect(gatewaySrc).toMatch(/topic_scope: topicScope/)
131
147
  // Only for topic inbounds — DMs get nothing.
132
- expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED && messageThreadId != null/)
148
+ // P7 PR-10: the guard reads the injected constant + params inside
149
+ // buildInboundEnvelope (topicFramingEnabled / p.messageThreadId).
150
+ expect(gatewaySrc).toMatch(/(?:TOPIC_FRAMING_ENABLED|p\.topicFramingEnabled) && (?:p\.)?messageThreadId != null/)
133
151
  })
134
152
 
135
153
  it('the bridge instructions frame each channel message as the current topic', () => {
@@ -151,17 +169,17 @@ describe('component 5 — queued-status UX (delete-on-answer)', () => {
151
169
  })
152
170
 
153
171
  it('Hook B promotes the placeholder to "On it" when the buffered turn starts', () => {
154
- expect(gatewaySrc).toMatch(/promoteQueuedStatus\(ev\.chatId, enqThreadIdNum\)/)
172
+ expect(gatewayAndStreamSrc).toMatch(/promoteQueuedStatus\(ev\.chatId, enqThreadIdNum\)/)
155
173
  })
156
174
 
157
175
  it('Hook C reaps the placeholder on the answer (executeReply / stream)', () => {
158
- const reapCalls = gatewaySrc.match(/reapQueuedStatus\(/g) ?? []
176
+ const reapCalls = (gatewaySrc + turnEndSrc).match(/reapQueuedStatus\(/g) ?? []
159
177
  // definition + executeReply + purge cleanup (2 branches)
160
178
  expect(reapCalls.length).toBeGreaterThanOrEqual(4)
161
179
  })
162
180
 
163
181
  it('purgeReactionTracking reaps the placeholder on abnormal turn-end (defense-in-depth)', () => {
164
- const fn = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
182
+ const fn = turnEndSrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
165
183
  expect(fn).toMatch(/reapQueuedStatus\(/)
166
184
  // Reap sits alongside the activeReactionMsgIds cleanup.
167
185
  expect(fn).toMatch(/activeReactionMsgIds\.delete\(key\)/)
@@ -3,6 +3,7 @@ import {
3
3
  normalizeNarrative,
4
4
  prefixSimilarity,
5
5
  isDraftOfReply,
6
+ isReplyTool,
6
7
  DRAFT_SUPPRESS_THRESHOLD,
7
8
  REPLY_TOOLS,
8
9
  } from '../narrative-dedup.js'
@@ -18,6 +19,37 @@ describe('narrative-dedup', () => {
18
19
  expect(REPLY_TOOLS.has('Bash')).toBe(false)
19
20
  })
20
21
 
22
+ describe('isReplyTool (#3231 prefix-aware matching)', () => {
23
+ it('matches the PREFIXED prod wire shape (what real jsonl actually carries)', () => {
24
+ // The whole point of #3231: production jsonl carries mcp__…__telegram__<tool>,
25
+ // so a bare REPLY_TOOLS.has(name) was always false in prod → suppression inert.
26
+ expect(isReplyTool('mcp__switchroom-telegram__stream_reply')).toBe(true)
27
+ expect(isReplyTool('mcp__switchroom-telegram__reply')).toBe(true)
28
+ })
29
+
30
+ it('matches under ANY registration key (clerk-telegram, forks)', () => {
31
+ expect(isReplyTool('mcp__clerk-telegram__stream_reply')).toBe(true)
32
+ expect(isReplyTool('mcp__my-fork-telegram__reply')).toBe(true)
33
+ })
34
+
35
+ it('still matches BARE names (non-MCP sources)', () => {
36
+ expect(isReplyTool('reply')).toBe(true)
37
+ expect(isReplyTool('stream_reply')).toBe(true)
38
+ })
39
+
40
+ it('rejects non-reply tools, prefixed or bare', () => {
41
+ expect(isReplyTool('Bash')).toBe(false)
42
+ expect(isReplyTool('mcp__switchroom-telegram__react')).toBe(false)
43
+ expect(isReplyTool('mcp__switchroom-telegram__edit_message')).toBe(false)
44
+ expect(isReplyTool('mcp__hindsight__retain')).toBe(false)
45
+ })
46
+
47
+ it('is null/undefined safe', () => {
48
+ expect(isReplyTool(null)).toBe(false)
49
+ expect(isReplyTool(undefined)).toBe(false)
50
+ })
51
+ })
52
+
21
53
  describe('normalizeNarrative', () => {
22
54
  it('strips markdown emphasis/heading/quote marks, collapses whitespace, lowercases', () => {
23
55
  expect(normalizeNarrative('**Bold** _italic_ `code`')).toBe('bold italic code')
@@ -101,7 +101,10 @@ describe('anti-double-print (deferred path) — a draft-then-send reply is suppr
101
101
  const answer = 'The build is green — all 412 tests pass.'
102
102
  ctrl.stage(answer) // the model drafting its answer just before reply()
103
103
 
104
- ctrl.resolveOnTool('reply', { text: answer }) // draft-then-send → SUPPRESS
104
+ // #3231: feed the PREFIXED prod wire shape (mcp__…__reply). Before the
105
+ // isReplyTool fix a bare REPLY_TOOLS.has() missed this and the draft was
106
+ // WRONGLY shown — the suppression path was inert on the real wire shape.
107
+ ctrl.resolveOnTool('mcp__switchroom-telegram__reply', { text: answer }) // draft-then-send → SUPPRESS
105
108
  expect(show).not.toHaveBeenCalled()
106
109
  expect(retractShown).not.toHaveBeenCalled() // nothing was shown to retract
107
110
  expect(scheduler.isArmed).toBe(false)
@@ -128,7 +131,8 @@ describe('anti-double-print (TIMER path) — a timer-painted draft is retracted'
128
131
 
129
132
  // The reply finally lands and IS that block → must be retracted, not left
130
133
  // on the card to double-print against the canonical reply.
131
- ctrl.resolveOnTool('stream_reply', { text: answer })
134
+ // #3231: prefixed prod wire shape.
135
+ ctrl.resolveOnTool('mcp__switchroom-telegram__stream_reply', { text: answer })
132
136
  expect(retractShown).toHaveBeenCalledTimes(1)
133
137
  expect(retractShown).toHaveBeenCalledWith(answer)
134
138
  expect(show).toHaveBeenCalledTimes(1) // not re-shown