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.
- package/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -227,39 +227,94 @@ describe('PendingUserNoticeGate — notice fires IFF the turn dies reply-less',
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// The single-turn norm: the error surfaced while topic A's turn was live, so
|
|
231
|
+
// the notice is keyed to A and topic A's turn end resolves it. `TOPIC_A` is a
|
|
232
|
+
// `statusKey(chatId, threadId)`-shaped value.
|
|
233
|
+
const TOPIC_A = '5000000002'
|
|
234
|
+
const TOPIC_B = '5000000009:42'
|
|
235
|
+
|
|
230
236
|
it('DROPS the notice when the turn recovered (delivered a reply)', () => {
|
|
231
237
|
const gate = new PendingUserNoticeGate()
|
|
232
|
-
gate.schedule(makeNotice())
|
|
233
|
-
expect(gate.resolveTurnEnd(true, 1_001_000)).toEqual([])
|
|
238
|
+
gate.schedule(makeNotice({ key: TOPIC_A }))
|
|
239
|
+
expect(gate.resolveTurnEnd(TOPIC_A, true, 1_001_000)).toEqual([])
|
|
234
240
|
// And it does not resurrect on a later failed turn end.
|
|
235
|
-
expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
|
|
241
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
|
|
236
242
|
})
|
|
237
243
|
|
|
238
244
|
it('SENDS the notice exactly once when the turn ends without a reply', () => {
|
|
239
245
|
const gate = new PendingUserNoticeGate()
|
|
240
|
-
|
|
241
|
-
gate.
|
|
242
|
-
const flushed = gate.resolveTurnEnd(false, 1_001_000)
|
|
246
|
+
gate.schedule(makeNotice({ key: TOPIC_A }))
|
|
247
|
+
const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_001_000)
|
|
243
248
|
expect(flushed).toHaveLength(1)
|
|
244
249
|
expect(flushed[0].chatIds).toEqual(['5000000002', '5000000003'])
|
|
245
250
|
// Exactly once — a second turn end flushes nothing.
|
|
246
|
-
expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
|
|
251
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
|
|
247
252
|
})
|
|
248
253
|
|
|
249
|
-
it('collapses an error burst within one turn to ONE pending notice per
|
|
254
|
+
it('collapses an error burst within one turn to ONE pending notice per topic', () => {
|
|
250
255
|
const gate = new PendingUserNoticeGate()
|
|
251
|
-
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
252
|
-
gate.schedule(makeNotice({ atMs: 1_000_500 }))
|
|
253
|
-
gate.schedule(makeNotice({ atMs: 1_001_000 }))
|
|
254
|
-
expect(gate.resolveTurnEnd(false, 1_002_000)).toHaveLength(1)
|
|
256
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
257
|
+
gate.schedule(makeNotice({ atMs: 1_000_500, key: TOPIC_A }))
|
|
258
|
+
gate.schedule(makeNotice({ atMs: 1_001_000, key: TOPIC_A }))
|
|
259
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toHaveLength(1)
|
|
255
260
|
})
|
|
256
261
|
|
|
257
262
|
it('expires an unresolved notice after the TTL (bias to silence over false failure)', () => {
|
|
258
263
|
const gate = new PendingUserNoticeGate()
|
|
259
|
-
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
264
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
260
265
|
const after = 1_000_000 + PENDING_USER_NOTICE_TTL_MS + 1
|
|
261
266
|
expect(gate.hasPending(after)).toBe(false)
|
|
262
|
-
expect(gate.resolveTurnEnd(false, after)).toEqual([])
|
|
267
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, after)).toEqual([])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// ── #3294 — keyed by turn/topic so concurrent per-topic turns don't
|
|
271
|
+
// cross-resolve each other's pending notices. ─────────────────────────────
|
|
272
|
+
it("does NOT flush topic A's notice when a DIFFERENT topic's turn ends reply-less", () => {
|
|
273
|
+
const gate = new PendingUserNoticeGate()
|
|
274
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
275
|
+
// Topic B's turn ends WITHOUT a reply. Pre-#3294 this flushed A's notice
|
|
276
|
+
// (a false "couldn't complete" for a turn that may still recover). Now B's
|
|
277
|
+
// end resolves nothing keyed to A.
|
|
278
|
+
expect(gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)).toEqual([])
|
|
279
|
+
// A's notice is still pending, and A's OWN reply-less end flushes it.
|
|
280
|
+
expect(gate.hasPending(1_000_600)).toBe(true)
|
|
281
|
+
const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)
|
|
282
|
+
expect(flushed).toHaveLength(1)
|
|
283
|
+
expect(flushed[0].key).toBe(TOPIC_A)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("does NOT drop topic A's notice when a DIFFERENT topic's turn recovers", () => {
|
|
287
|
+
const gate = new PendingUserNoticeGate()
|
|
288
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
|
|
289
|
+
// Topic B's turn recovered (delivered a reply). It must not drop A's notice.
|
|
290
|
+
expect(gate.resolveTurnEnd(TOPIC_B, true, 1_000_500)).toEqual([])
|
|
291
|
+
// A's notice survives and still flushes on A's reply-less end.
|
|
292
|
+
expect(gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)).toHaveLength(1)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('keeps a per-topic pending notice for each concurrent topic (no cross-collapse)', () => {
|
|
296
|
+
const gate = new PendingUserNoticeGate()
|
|
297
|
+
gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A, chatIds: ['5000000002'] }))
|
|
298
|
+
gate.schedule(makeNotice({ atMs: 1_000_100, key: TOPIC_B, chatIds: ['5000000009'] }))
|
|
299
|
+
// A's end flushes only A.
|
|
300
|
+
const a = gate.resolveTurnEnd(TOPIC_A, false, 1_000_200)
|
|
301
|
+
expect(a).toHaveLength(1)
|
|
302
|
+
expect(a[0].chatIds).toEqual(['5000000002'])
|
|
303
|
+
// B is still pending and flushes on B's end.
|
|
304
|
+
const b = gate.resolveTurnEnd(TOPIC_B, false, 1_000_300)
|
|
305
|
+
expect(b).toHaveLength(1)
|
|
306
|
+
expect(b[0].chatIds).toEqual(['5000000009'])
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('an UNATTRIBUTED (undefined-key) notice resolves on any turn end (legacy single-turn behavior)', () => {
|
|
310
|
+
const gate = new PendingUserNoticeGate()
|
|
311
|
+
// No key — the error arrived with no live turn (between turns / post-poke).
|
|
312
|
+
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
313
|
+
// Any topic's reply-less end flushes it (preserves pre-#3294 behavior so a
|
|
314
|
+
// genuine death on a single-turn gateway still notifies the user).
|
|
315
|
+
const flushed = gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)
|
|
316
|
+
expect(flushed).toHaveLength(1)
|
|
317
|
+
expect(flushed[0].key).toBeUndefined()
|
|
263
318
|
})
|
|
264
319
|
})
|
|
265
320
|
|
|
@@ -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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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", () => {
|
|
@@ -12,10 +12,33 @@ import { describe, it, expect } from 'vitest'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { resolve } from 'node:path'
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// #2996 P2: executeReply's body moved VERBATIM to outbound-send-path.ts
|
|
16
|
+
// (`sendReply`); the reply-path routing assertions read the window there.
|
|
17
|
+
const sendPathSrc = readFileSync(
|
|
18
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
17
19
|
'utf-8',
|
|
18
20
|
)
|
|
21
|
+
const sendReplyFn = sendPathSrc.split('export async function sendReply(')[1]?.split('\nexport ')[0] ?? ''
|
|
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'),
|
|
35
|
+
'utf-8',
|
|
36
|
+
)
|
|
37
|
+
const streamSrc = readFileSync(
|
|
38
|
+
resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
|
|
39
|
+
'utf-8',
|
|
40
|
+
)
|
|
41
|
+
const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
|
|
19
42
|
const bridgeSrc = readFileSync(
|
|
20
43
|
resolve(__dirname, '..', 'bridge', 'bridge.ts'),
|
|
21
44
|
'utf-8',
|
|
@@ -31,12 +54,12 @@ const deriveTurnIdSrc = readFileSync(
|
|
|
31
54
|
describe('component 3 — turn-origin reply routing', () => {
|
|
32
55
|
it('CurrentTurn carries a turnId, and the enqueue handler initialises it', () => {
|
|
33
56
|
expect(gatewaySrc).toMatch(/turnId: string/)
|
|
34
|
-
expect(
|
|
35
|
-
expect(
|
|
57
|
+
expect(gatewayAndStreamSrc).toMatch(/const turnId\s*=\s*\n?\s*deriveTurnId\(/)
|
|
58
|
+
expect(gatewayAndStreamSrc).toMatch(/rememberRecentTurn\(next\)/)
|
|
36
59
|
})
|
|
37
60
|
|
|
38
61
|
it('the inbound meta stamps origin_turn_id derived from chat/thread/messageId', () => {
|
|
39
|
-
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\)/)
|
|
40
63
|
expect(gatewaySrc).toMatch(/origin_turn_id: originTurnId/)
|
|
41
64
|
})
|
|
42
65
|
|
|
@@ -54,7 +77,7 @@ describe('component 3 — turn-origin reply routing', () => {
|
|
|
54
77
|
})
|
|
55
78
|
|
|
56
79
|
it('executeReply resolves the answer thread via the origin turn, not the live currentTurn', () => {
|
|
57
|
-
const fn =
|
|
80
|
+
const fn = sendReplyFn
|
|
58
81
|
expect(fn).toMatch(/TURN_ORIGIN_ROUTING_ENABLED/)
|
|
59
82
|
expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
|
|
60
83
|
// The resolution + reply-route telemetry go through resolveAnswerThreadWithLog,
|
|
@@ -85,8 +108,7 @@ describe('framework-owned origin recovery (determinism residual, 2026-06-05)', (
|
|
|
85
108
|
})
|
|
86
109
|
|
|
87
110
|
it('the reply path recovers origin from the quoted message_id when the model omits the echo', () => {
|
|
88
|
-
for (const
|
|
89
|
-
const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
|
|
111
|
+
for (const fn of [sendReplyFn]) {
|
|
90
112
|
// Echo first (authoritative), quoted message_id as the framework fallback.
|
|
91
113
|
expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
|
|
92
114
|
// Quoted lookup is CHAT-SCOPED (cross-chat message-id collision guard).
|
|
@@ -123,7 +145,9 @@ describe('component 4 — per-turn topic framing', () => {
|
|
|
123
145
|
expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED/)
|
|
124
146
|
expect(gatewaySrc).toMatch(/topic_scope: topicScope/)
|
|
125
147
|
// Only for topic inbounds — DMs get nothing.
|
|
126
|
-
|
|
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/)
|
|
127
151
|
})
|
|
128
152
|
|
|
129
153
|
it('the bridge instructions frame each channel message as the current topic', () => {
|
|
@@ -145,17 +169,17 @@ describe('component 5 — queued-status UX (delete-on-answer)', () => {
|
|
|
145
169
|
})
|
|
146
170
|
|
|
147
171
|
it('Hook B promotes the placeholder to "On it" when the buffered turn starts', () => {
|
|
148
|
-
expect(
|
|
172
|
+
expect(gatewayAndStreamSrc).toMatch(/promoteQueuedStatus\(ev\.chatId, enqThreadIdNum\)/)
|
|
149
173
|
})
|
|
150
174
|
|
|
151
175
|
it('Hook C reaps the placeholder on the answer (executeReply / stream)', () => {
|
|
152
|
-
const reapCalls = gatewaySrc.match(/reapQueuedStatus\(/g) ?? []
|
|
176
|
+
const reapCalls = (gatewaySrc + turnEndSrc).match(/reapQueuedStatus\(/g) ?? []
|
|
153
177
|
// definition + executeReply + purge cleanup (2 branches)
|
|
154
178
|
expect(reapCalls.length).toBeGreaterThanOrEqual(4)
|
|
155
179
|
})
|
|
156
180
|
|
|
157
181
|
it('purgeReactionTracking reaps the placeholder on abnormal turn-end (defense-in-depth)', () => {
|
|
158
|
-
const fn =
|
|
182
|
+
const fn = turnEndSrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
|
|
159
183
|
expect(fn).toMatch(/reapQueuedStatus\(/)
|
|
160
184
|
// Reap sits alongside the activeReactionMsgIds cleanup.
|
|
161
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
|
-
|
|
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
|
-
|
|
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
|