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.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- 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/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/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/gateway.ts +1347 -6758
- 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-wiring.ts +333 -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/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- 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/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 +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -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 +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- 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-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- 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 +27 -9
- 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/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- 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/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 +28 -18
- 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 +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -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 +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- 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 +10 -4
|
@@ -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
|
+
})
|
|
@@ -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++) {
|
|
@@ -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 =
|
|
35
|
-
resolve(__dirname, "..", "gateway", "gateway.ts"),
|
|
36
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
expect(
|
|
68
|
-
const
|
|
69
|
-
|
|
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', () => {
|
|
@@ -98,10 +98,12 @@ describe('anthropic_oauth_code pattern — Channel A', () => {
|
|
|
98
98
|
// ─── Channel B — context rule structural tests ────────────────────────────────
|
|
99
99
|
|
|
100
100
|
describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)', () => {
|
|
101
|
-
const src =
|
|
102
|
-
new URL('../gateway/gateway.ts', import.meta.url),
|
|
103
|
-
'
|
|
104
|
-
|
|
101
|
+
const src =
|
|
102
|
+
readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8') +
|
|
103
|
+
'\n' +
|
|
104
|
+
// P7 PR-9 (#2996): the secret-detect pipeline intercept moved verbatim
|
|
105
|
+
// into gateway/inbound-interceptors.ts (interceptSecretDetectPipeline).
|
|
106
|
+
readFileSync(new URL('../gateway/inbound-interceptors.ts', import.meta.url), 'utf8')
|
|
105
107
|
|
|
106
108
|
it('declares awaitingAuthCodeAt map and AUTH_CODE_CONTEXT_TTL_MS constant', () => {
|
|
107
109
|
expect(src).toMatch(/const awaitingAuthCodeAt = new Map<string, number>/)
|
|
@@ -121,7 +123,7 @@ describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)
|
|
|
121
123
|
|
|
122
124
|
it('clears awaitingAuthCodeAt (delete) in the inbound handler when the flag is active', () => {
|
|
123
125
|
// The inbound handler must call delete after reading the flag
|
|
124
|
-
expect(src).toMatch(/awaitingAuthCodeAt\.delete\(chat_id\)/)
|
|
126
|
+
expect(src).toMatch(/awaitingAuthCodeAt\.delete\((?:p\.)?chat_id\)/)
|
|
125
127
|
})
|
|
126
128
|
|
|
127
129
|
it('checks isAuthFlowContext in the secret-detect block (passphrase path)', () => {
|
|
@@ -155,7 +157,9 @@ describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)
|
|
|
155
157
|
})
|
|
156
158
|
|
|
157
159
|
it('auth-flow context rule sits BEFORE recordInbound() and broadcast()', () => {
|
|
158
|
-
|
|
160
|
+
// P7 PR-9: the context rule lives inside interceptSecretDetectPipeline;
|
|
161
|
+
// its gateway CALL SITE is the ordering anchor against the sinks.
|
|
162
|
+
const contextIdx = src.indexOf('await interceptSecretDetectPipeline(')
|
|
159
163
|
const recordIdx = src.indexOf('recordInbound(', contextIdx)
|
|
160
164
|
const broadcastIdx = src.indexOf('ipcServer.broadcast(inboundMsg)', contextIdx)
|
|
161
165
|
expect(contextIdx).toBeGreaterThan(0)
|
|
@@ -191,8 +195,12 @@ describe('anthropic_oauth_code pattern — URL false-positive regression', () =>
|
|
|
191
195
|
// Test 2: Blocker 1 sequencing — structural check that deleteMessage is called
|
|
192
196
|
// inside the pendingReauthFlows intercept path, before setMessageReaction.
|
|
193
197
|
describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)', () => {
|
|
198
|
+
// P7 PR-7 (#2996): the reauth intercept body moved verbatim into
|
|
199
|
+
// gateway/inbound-interceptors.ts (interceptReauth); the redaction is the
|
|
200
|
+
// injected deps.redactAuthCode closure over redactAuthCodeMessage +
|
|
201
|
+
// redactAuthCodeApi (bound in gateway.ts). Same pins, new file owner.
|
|
194
202
|
const src = readFileSync(
|
|
195
|
-
new URL('../gateway/
|
|
203
|
+
new URL('../gateway/inbound-interceptors.ts', import.meta.url),
|
|
196
204
|
'utf8',
|
|
197
205
|
)
|
|
198
206
|
|
|
@@ -202,7 +210,7 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
|
|
|
202
210
|
// `setMessageReaction(...)` literals, but #488 consolidated all 6
|
|
203
211
|
// auth-code paste paths through `redactAuthCodeMessage`. The pin
|
|
204
212
|
// is now: the intercept block must call the helper.
|
|
205
|
-
const interceptIdx = src.indexOf('
|
|
213
|
+
const interceptIdx = src.indexOf('export async function interceptReauth(')
|
|
206
214
|
expect(interceptIdx).toBeGreaterThan(0)
|
|
207
215
|
|
|
208
216
|
// Find the end of this intercept block — the next blank line after
|
|
@@ -215,7 +223,7 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
|
|
|
215
223
|
// (optionally cast, #623), now the gated `redactAuthCodeApi` adapter whose
|
|
216
224
|
// reaction routes through the send gate + flood breaker (#3155). `msgId`
|
|
217
225
|
// may be narrowed (`msgId ?? null`).
|
|
218
|
-
expect(window).toMatch(/
|
|
226
|
+
expect(window).toMatch(/deps\.redactAuthCode\(p\.chat_id,\s*p\.msgId(?:\s*\?\?\s*null)?\)/)
|
|
219
227
|
})
|
|
220
228
|
|
|
221
229
|
it('redaction lands AFTER the success/error reply renders', () => {
|
|
@@ -224,10 +232,10 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
|
|
|
224
232
|
// even if their original message disappears mid-render. Same
|
|
225
233
|
// ordering the helper preserves — fire-and-forget redaction
|
|
226
234
|
// happens after `await switchroomReply(...)`.
|
|
227
|
-
const interceptIdx = src.indexOf('
|
|
235
|
+
const interceptIdx = src.indexOf('export async function interceptReauth(')
|
|
228
236
|
const window = src.slice(interceptIdx, interceptIdx + 2000)
|
|
229
|
-
const replyIdx = window.indexOf('switchroomReply(
|
|
230
|
-
const redactIdx = window.indexOf('
|
|
237
|
+
const replyIdx = window.indexOf('deps.switchroomReply(')
|
|
238
|
+
const redactIdx = window.indexOf('deps.redactAuthCode(')
|
|
231
239
|
expect(replyIdx).toBeGreaterThan(0)
|
|
232
240
|
expect(redactIdx).toBeGreaterThan(0)
|
|
233
241
|
expect(replyIdx).toBeLessThan(redactIdx)
|
|
@@ -237,10 +245,12 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
|
|
|
237
245
|
// Test 3: Flag consumption — awaitingAuthCodeAt is NOT cleared on a non-detection
|
|
238
246
|
// inbound (a stray "ok" within the 5-min window should not disarm Channel B).
|
|
239
247
|
describe('awaitingAuthCodeAt flag — consumption only on actual detection', () => {
|
|
240
|
-
const src =
|
|
241
|
-
new URL('../gateway/gateway.ts', import.meta.url),
|
|
242
|
-
'
|
|
243
|
-
|
|
248
|
+
const src =
|
|
249
|
+
readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8') +
|
|
250
|
+
'\n' +
|
|
251
|
+
// P7 PR-9 (#2996): the secret-detect pipeline intercept moved verbatim
|
|
252
|
+
// into gateway/inbound-interceptors.ts (interceptSecretDetectPipeline).
|
|
253
|
+
readFileSync(new URL('../gateway/inbound-interceptors.ts', import.meta.url), 'utf8')
|
|
244
254
|
|
|
245
255
|
it('delete(chat_id) does NOT appear at the top of the isAuthFlowContext block (no early consume)', () => {
|
|
246
256
|
// The old bug: delete fired unconditionally inside `if (isAuthFlowContext)`.
|
|
@@ -261,7 +271,7 @@ describe('awaitingAuthCodeAt flag — consumption only on actual detection', ()
|
|
|
261
271
|
expect(fallbackIdx).toBeGreaterThan(0)
|
|
262
272
|
// Within 400 chars after the comment, delete must appear
|
|
263
273
|
const window = src.slice(fallbackIdx, fallbackIdx + 400)
|
|
264
|
-
expect(window).toMatch(/awaitingAuthCodeAt\.delete\(chat_id\)/)
|
|
274
|
+
expect(window).toMatch(/(?:deps\.)?awaitingAuthCodeAt\.delete\((?:p\.)?chat_id\)/)
|
|
265
275
|
})
|
|
266
276
|
|
|
267
277
|
it('awaitingAuthCodeAt.delete appears inside the no-passphrase hasHigh branch', () => {
|
|
@@ -272,7 +282,7 @@ describe('awaitingAuthCodeAt flag — consumption only on actual detection', ()
|
|
|
272
282
|
const window = src.slice(noPpIdx, noPpIdx + 1200)
|
|
273
283
|
// Conditional consume: only fires if isAuthFlowContext
|
|
274
284
|
expect(window).toMatch(/if \(isAuthFlowContext\)/)
|
|
275
|
-
expect(window).toMatch(/awaitingAuthCodeAt\.delete\(chat_id\)/)
|
|
285
|
+
expect(window).toMatch(/(?:deps\.)?awaitingAuthCodeAt\.delete\((?:p\.)?chat_id\)/)
|
|
276
286
|
})
|
|
277
287
|
})
|
|
278
288
|
|
|
@@ -24,6 +24,15 @@ import { readFileSync } from 'node:fs'
|
|
|
24
24
|
import { resolve } from 'node:path'
|
|
25
25
|
|
|
26
26
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
27
|
+
// #2996 P4-A: the model-driven reset sites (tool_label append, answer-stream
|
|
28
|
+
// onMetric) live inside handleSessionEvent, which moved VERBATIM to
|
|
29
|
+
// stream-render.ts. There the live-turn guard reads the injected accessor
|
|
30
|
+
// (`getCurrentTurn() === turn`) rather than the `currentTurn` module global.
|
|
31
|
+
const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
|
|
32
|
+
// #2996 P4-B: drainActivitySummary / feedHeartbeatTick moved VERBATIM into
|
|
33
|
+
// narrative-lane.ts (factory scope, bodies indented +2; gateway keeps thin
|
|
34
|
+
// same-name wrappers) — the heartbeat-safety windows read the module source.
|
|
35
|
+
const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
|
|
27
36
|
|
|
28
37
|
function between(src: string, startMarker: string, endMarker: string): string {
|
|
29
38
|
const after = src.split(startMarker)[1] ?? ''
|
|
@@ -32,13 +41,13 @@ function between(src: string, startMarker: string, endMarker: string): string {
|
|
|
32
41
|
|
|
33
42
|
describe('silence-poke production-liveness — heartbeat safety', () => {
|
|
34
43
|
it('drainActivitySummary must NOT reset the silence clock (the framework heartbeat drains here)', () => {
|
|
35
|
-
const body = between(
|
|
44
|
+
const body = between(laneSrc, 'async function drainActivitySummary', '\n function feedHeartbeatTick')
|
|
36
45
|
expect(body.length).toBeGreaterThan(100) // sanity: the slice found the function body
|
|
37
46
|
expect(body).not.toMatch(/noteProduction/)
|
|
38
47
|
})
|
|
39
48
|
|
|
40
49
|
it('feedHeartbeatTick itself must NOT reset the silence clock (model-independent re-render)', () => {
|
|
41
|
-
const body = between(
|
|
50
|
+
const body = between(laneSrc, 'function feedHeartbeatTick(): void {', '\n }')
|
|
42
51
|
expect(body.length).toBeGreaterThan(50)
|
|
43
52
|
expect(body).not.toMatch(/noteProduction/)
|
|
44
53
|
})
|
|
@@ -47,18 +56,18 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
|
|
|
47
56
|
// appendActivityLabel returns a fresh render only when the model emits a NEW
|
|
48
57
|
// labelled step — the genuine liveness signal the heartbeat can never forge.
|
|
49
58
|
const block = between(
|
|
50
|
-
|
|
59
|
+
streamSrc,
|
|
51
60
|
'const rendered = appendActivityLabel(turn.mirrorLines, ev.label)',
|
|
52
61
|
'\n return',
|
|
53
62
|
)
|
|
54
63
|
expect(block).toMatch(/silencePoke\.noteProduction/)
|
|
55
|
-
expect(block).toMatch(/
|
|
64
|
+
expect(block).toMatch(/getCurrentTurn\(\) === turn/)
|
|
56
65
|
})
|
|
57
66
|
|
|
58
67
|
it('the answer-stream draft onMetric reset is model-driven and gated on the live turn', () => {
|
|
59
|
-
const block = between(
|
|
68
|
+
const block = between(streamSrc, 'onMetric: (metricEv) => {', '\n },')
|
|
60
69
|
expect(block).toMatch(/silencePoke\.noteProduction/)
|
|
61
|
-
expect(block).toMatch(/
|
|
70
|
+
expect(block).toMatch(/getCurrentTurn\(\) === turn/)
|
|
62
71
|
})
|
|
63
72
|
|
|
64
73
|
it('production-liveness is behind the default-ON SWITCHROOM_SILENCE_LIVENESS_PRODUCTION kill switch', () => {
|
|
@@ -74,12 +83,17 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
|
|
|
74
83
|
// Structural guard so a refactor can't silently re-silence the re-ping
|
|
75
84
|
// (the gateway IIFE can't be instantiated in-process — same pattern as the
|
|
76
85
|
// heartbeat-safety assertions above).
|
|
77
|
-
|
|
86
|
+
// #2996 P8 PR-C3: the fallback body moved verbatim to liveness-wiring.ts;
|
|
87
|
+
// the send itself is the injected `sendSilenceText(chat, thread, text,
|
|
88
|
+
// silent)` closure (gateway keeps the bot-api touch), so the gate now
|
|
89
|
+
// rides the closure's `silent` argument at the callsite.
|
|
90
|
+
const livenessSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf-8')
|
|
91
|
+
const block = between(livenessSrc, 'onFrameworkFallback: async (ctx) => {', '\n }\n}')
|
|
78
92
|
expect(block.length).toBeGreaterThan(100) // sanity: slice found the handler body
|
|
79
93
|
// The signal is derived once, hoisted above the update-status branch so it's
|
|
80
94
|
// in scope at the send site.
|
|
81
95
|
expect(block).toMatch(/const blockedOnApproval = activeStatusReactions/)
|
|
82
96
|
// The send gates on it rather than hard-coding silent.
|
|
83
|
-
expect(block).toMatch(/
|
|
97
|
+
expect(block).toMatch(/sendSilenceText\(ctx\.chatId, ctx\.threadId \?\? null, text, blockedOnApproval \? false : true\)/)
|
|
84
98
|
})
|
|
85
99
|
})
|