switchroom 0.18.8 → 0.18.9

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 (36) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +2 -2
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
  6. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  7. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  8. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  9. package/telegram-plugin/gateway/gateway.ts +527 -2880
  10. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  11. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  13. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  14. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  15. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  16. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  17. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  18. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  20. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  21. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  22. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  23. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  24. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  26. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  27. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  29. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  30. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  31. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  32. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  33. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  34. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  35. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  36. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Invocable send-loop harness (#2996 step 1).
3
+ *
4
+ * PR 3007 extracted the deterministic text/chunk CORE of the outbound path but
5
+ * noted the side-effecting send ORCHESTRATION could not be moved (or tested)
6
+ * without an "invocable-executeReply harness" — because gateway.ts is not
7
+ * importable in any test runner: it runs boot logic (acquires the PID lock,
8
+ * `process.exit(1)` when another gateway is live) and calls `Bun.listen` at
9
+ * import time, so `executeReply` can never be driven from vitest/bun in-place.
10
+ *
11
+ * This harness closes that gap for the highest-bug-density mechanic of the send
12
+ * path — the chunk-send loop with its THREAD_NOT_FOUND / oversize re-split /
13
+ * parse-reject fallback ladder and partial-failure contract (exactly where the
14
+ * recent oversize / wire-cap fixes landed). `sendReplyChunks` was relocated
15
+ * VERBATIM from executeReply and is now driven here against a FAKE bot API,
16
+ * asserting: multi-chunk send order, partial-failure contract (which chunks
17
+ * report sent), oversize re-send, HTML parse-reject plaintext fallback,
18
+ * THREAD_NOT_FOUND thread-drop + retry, keyboard-on-last-chunk passthrough,
19
+ * preview edit-in-place, and voice-only text suppression.
20
+ *
21
+ * The gateway keeps the raw `bot.api.*` calls as thin injected adapters; the
22
+ * module is bot-agnostic, so a fake is a plain record of function calls. The
23
+ * cross-surface `outboundDedup` singleton contract is covered by the sibling
24
+ * `outbound-send-path.test.ts` (a stream-path `record` suppresses a reply-path
25
+ * `check` on the same instance) — dedup lives in executeReply, not in this
26
+ * relocated loop, so it is intentionally out of scope here.
27
+ */
28
+ import { describe, it, expect } from 'vitest'
29
+ import { GrammyError } from 'grammy'
30
+ import {
31
+ sendReplyChunks,
32
+ type ReplyChunkSendDeps,
33
+ type ReplyChunkSendState,
34
+ } from '../gateway/outbound-send-path.js'
35
+
36
+ // Build a GrammyError with a given 400 description (grammy 1.44 ctor shape).
37
+ function grammy400(description: string, method = 'sendRichMessage'): GrammyError {
38
+ return new GrammyError(
39
+ `Call to '${method}' failed! (400: ${description})`,
40
+ { ok: false, error_code: 400, description },
41
+ method,
42
+ {},
43
+ )
44
+ }
45
+
46
+ interface RecordedCall {
47
+ kind: 'sendRich' | 'sendLiteral' | 'sendLiteralRaw' | 'sendRichRaw' | 'editPreview'
48
+ opts: Record<string, unknown>
49
+ body: unknown
50
+ threadId?: number | undefined
51
+ messageId?: number
52
+ }
53
+
54
+ /**
55
+ * Fake bot surface. `sendRich`/`sendLiteral` are the retry-wrapped adapters;
56
+ * `*Raw` are the unwrapped last-resort adapters; `editPreview` is the
57
+ * preview edit-in-place. Each records its call; per-kind `failers` inject
58
+ * one-shot errors (keyed by chunk-body identity or call index) so we can
59
+ * script the fallback ladder deterministically.
60
+ */
61
+ function makeFake(opts?: {
62
+ failNext?: Partial<Record<RecordedCall['kind'], Array<unknown>>>
63
+ richMessage?: (s: string) => unknown
64
+ }): {
65
+ deps: ReplyChunkSendDeps
66
+ calls: RecordedCall[]
67
+ stderr: string[]
68
+ logs: Array<{ id: number; chars: number; extra?: string }>
69
+ deleted: number[]
70
+ } {
71
+ const calls: RecordedCall[] = []
72
+ const stderr: string[] = []
73
+ const logs: Array<{ id: number; chars: number; extra?: string }> = []
74
+ const deleted: number[] = []
75
+ let nextId = 1000
76
+ const queues = opts?.failNext ?? {}
77
+
78
+ const maybeFail = (kind: RecordedCall['kind']): void => {
79
+ const q = queues[kind]
80
+ if (q && q.length > 0) {
81
+ const e = q.shift()
82
+ if (e != null) throw e
83
+ }
84
+ }
85
+ const send = (
86
+ kind: RecordedCall['kind'],
87
+ o: Record<string, unknown>,
88
+ body: unknown,
89
+ threadId?: number | undefined,
90
+ ): Promise<{ message_id: number }> => {
91
+ // Evaluate the failure queue synchronously so a thrown GrammyError
92
+ // rejects the returned promise exactly as the real adapter would.
93
+ return (async () => {
94
+ maybeFail(kind)
95
+ const id = ++nextId
96
+ calls.push({ kind, opts: o, body, threadId, messageId: id })
97
+ return { message_id: id }
98
+ })()
99
+ }
100
+
101
+ const deps: ReplyChunkSendDeps = {
102
+ sendRich: (o, body, tid) => send('sendRich', o, body, tid),
103
+ sendLiteral: (o, txt, tid) => send('sendLiteral', o, txt, tid),
104
+ sendLiteralRaw: (o, txt) => send('sendLiteralRaw', o, txt),
105
+ sendRichRaw: (o, body) => send('sendRichRaw', o, body),
106
+ editPreview: async (mid, body, o, tid) => {
107
+ maybeFail('editPreview')
108
+ calls.push({ kind: 'editPreview', opts: o, body, threadId: tid, messageId: mid })
109
+ return {}
110
+ },
111
+ richMessage: opts?.richMessage ?? ((s: string) => ({ rich: s })),
112
+ logOutbound: (_path, _chat, id, chars, extra) => { logs.push({ id, chars, extra }) },
113
+ deleteStalePreview: async (id) => { deleted.push(id) },
114
+ stderr: (s) => { stderr.push(s) },
115
+ }
116
+ return { deps, calls, stderr, logs, deleted }
117
+ }
118
+
119
+ function baseState(over: Partial<ReplyChunkSendState> & { chunks: string[] }): ReplyChunkSendState {
120
+ return {
121
+ chatId: '555',
122
+ literalText: false,
123
+ suppressText: false,
124
+ threadId: undefined,
125
+ previewMessageId: null,
126
+ sentIds: [],
127
+ buildSendOpts: (i, isLastChunk, tid) => ({
128
+ ...(tid != null ? { message_thread_id: tid } : {}),
129
+ ...(isLastChunk ? { _last: true } : {}),
130
+ _chunkIndex: i,
131
+ }),
132
+ buildPreviewEditOpts: () => ({}),
133
+ ...over,
134
+ }
135
+ }
136
+
137
+ describe('sendReplyChunks — multi-chunk send order', () => {
138
+ it('sends every chunk in order and appends ids to the shared sentIds array', async () => {
139
+ const { deps, calls } = makeFake()
140
+ const state = baseState({ chunks: ['a', 'b', 'c'] })
141
+ const res = await sendReplyChunks(deps, state)
142
+ expect(calls.map((c) => c.kind)).toEqual(['sendRich', 'sendRich', 'sendRich'])
143
+ expect(calls.map((c) => c.body)).toEqual([{ rich: 'a' }, { rich: 'b' }, { rich: 'c' }])
144
+ // ids appended in order, one per chunk
145
+ expect(state.sentIds).toHaveLength(3)
146
+ expect(state.sentIds).toEqual([...state.sentIds].sort((x, y) => x - y))
147
+ expect(res.threadId).toBeUndefined()
148
+ })
149
+
150
+ it('literal (format:text) chunks route through sendLiteral, never rich', async () => {
151
+ const { deps, calls } = makeFake()
152
+ const state = baseState({ chunks: ['x', 'y'], literalText: true })
153
+ await sendReplyChunks(deps, state)
154
+ expect(calls.map((c) => c.kind)).toEqual(['sendLiteral', 'sendLiteral'])
155
+ expect(calls.map((c) => c.body)).toEqual(['x', 'y'])
156
+ })
157
+
158
+ it('voice-only suppressText sends NOTHING (voice note IS the reply)', async () => {
159
+ const { deps, calls } = makeFake()
160
+ const state = baseState({ chunks: ['a', 'b'], suppressText: true })
161
+ await sendReplyChunks(deps, state)
162
+ expect(calls).toHaveLength(0)
163
+ expect(state.sentIds).toHaveLength(0)
164
+ })
165
+ })
166
+
167
+ describe('sendReplyChunks — keyboard/opts passthrough', () => {
168
+ it('reply_markup rides ONLY on the last chunk (isLastChunk gate)', async () => {
169
+ const { deps, calls } = makeFake()
170
+ const state = baseState({
171
+ chunks: ['one', 'two', 'three'],
172
+ buildSendOpts: (i, isLastChunk, tid) => ({
173
+ ...(tid != null ? { message_thread_id: tid } : {}),
174
+ ...(isLastChunk ? { reply_markup: { inline_keyboard: [[{ text: 'Go', callback_data: 'x' }]] } } : {}),
175
+ }),
176
+ })
177
+ await sendReplyChunks(deps, state)
178
+ expect(calls[0].opts.reply_markup).toBeUndefined()
179
+ expect(calls[1].opts.reply_markup).toBeUndefined()
180
+ expect(calls[2].opts.reply_markup).toBeDefined()
181
+ })
182
+
183
+ it('rich path strips link_preview_options before sending (rich entity previews)', async () => {
184
+ const { deps, calls } = makeFake()
185
+ const state = baseState({
186
+ chunks: ['only'],
187
+ buildSendOpts: () => ({ link_preview_options: { is_disabled: true }, keepme: 1 }),
188
+ })
189
+ await sendReplyChunks(deps, state)
190
+ expect(calls[0].opts.link_preview_options).toBeUndefined()
191
+ expect(calls[0].opts.keepme).toBe(1)
192
+ })
193
+ })
194
+
195
+ describe('sendReplyChunks — partial-failure contract', () => {
196
+ it('an unrecoverable error midway propagates, and sentIds holds ONLY the chunks already sent', async () => {
197
+ // chunk 0 ok, chunk 1 throws a non-400 (not length, not parse) → rethrow.
198
+ const boom = new Error('network exploded')
199
+ const { deps, calls } = makeFake({ failNext: { sendRich: [undefined, boom] } })
200
+ const state = baseState({ chunks: ['first', 'second', 'third'] })
201
+ await expect(sendReplyChunks(deps, state)).rejects.toThrow('network exploded')
202
+ // first chunk reported sent; second/third never appended.
203
+ expect(state.sentIds).toHaveLength(1)
204
+ // exactly one send succeeded (chunk 1 threw before being recorded).
205
+ expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(1)
206
+ })
207
+ })
208
+
209
+ describe('sendReplyChunks — oversize re-send', () => {
210
+ it('a RICH_MESSAGE_TEXT_TOO_LONG on the wrapped send re-splits via the raw rich adapter', async () => {
211
+ // A chunk far past the wire cap so resplitOversizeChunk yields >1 piece.
212
+ const huge = 'x'.repeat(70_000)
213
+ const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400('RICH_MESSAGE_TEXT_TOO_LONG')] } })
214
+ const state = baseState({ chunks: [huge] })
215
+ await sendReplyChunks(deps, state)
216
+ const raws = calls.filter((c) => c.kind === 'sendRichRaw')
217
+ expect(raws.length).toBeGreaterThan(1)
218
+ // every re-split piece landed a message id
219
+ expect(state.sentIds.length).toBe(raws.length)
220
+ })
221
+ })
222
+
223
+ describe('sendReplyChunks — parse-reject plaintext fallback', () => {
224
+ it("a can't-parse-entities 400 resends the chunk as plain text via sendLiteralRaw", async () => {
225
+ const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400("can't parse entities: bad offset")] } })
226
+ const state = baseState({ chunks: ['**bad markdown'] })
227
+ await sendReplyChunks(deps, state)
228
+ expect(calls.map((c) => c.kind)).toEqual(['sendLiteralRaw'])
229
+ expect(calls[0].body).toBe('**bad markdown')
230
+ expect(state.sentIds).toHaveLength(1)
231
+ })
232
+
233
+ it('an empty chunk parse-reject falls back to the placeholder glyph', async () => {
234
+ const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400("can't parse entities")] } })
235
+ const state = baseState({ chunks: [''] })
236
+ await sendReplyChunks(deps, state)
237
+ expect(calls[0].kind).toBe('sendLiteralRaw')
238
+ expect(String(calls[0].body)).toContain('could not be rendered')
239
+ })
240
+ })
241
+
242
+ describe('sendReplyChunks — THREAD_NOT_FOUND fallback', () => {
243
+ it('drops the thread and retries UNWRAPPED, and returns threadId undefined', async () => {
244
+ // wrapped send throws THREAD_NOT_FOUND; retry (raw) succeeds.
245
+ const tnf = new Error('THREAD_NOT_FOUND')
246
+ const { deps, calls } = makeFake({ failNext: { sendRich: [tnf] } })
247
+ const state = baseState({ chunks: ['hi'], threadId: 42 })
248
+ const res = await sendReplyChunks(deps, state)
249
+ // The first (wrapped) attempt threw before recording; the retry uses
250
+ // sendChunk(_, false) → the UNWRAPPED raw adapter, deliberately not
251
+ // re-entering the retry policy after a dropped thread.
252
+ expect(calls[0].kind).toBe('sendRichRaw')
253
+ // retry re-built opts WITHOUT the thread id.
254
+ expect(res.threadId).toBeUndefined()
255
+ expect(calls[0].opts.message_thread_id).toBeUndefined()
256
+ expect(state.sentIds).toHaveLength(1)
257
+ })
258
+
259
+ it('THREAD_NOT_FOUND then a length error on retry re-splits', async () => {
260
+ const tnf = new Error('THREAD_NOT_FOUND')
261
+ const huge = 'y'.repeat(70_000)
262
+ const { deps, calls } = makeFake({
263
+ failNext: { sendRich: [tnf], sendRichRaw: [grammy400('MESSAGE_TOO_LONG')] },
264
+ })
265
+ const state = baseState({ chunks: [huge], threadId: 7 })
266
+ await sendReplyChunks(deps, state)
267
+ // retry (raw) threw length → resplit sent multiple raw pieces
268
+ const raws = calls.filter((c) => c.kind === 'sendRichRaw')
269
+ expect(raws.length).toBeGreaterThan(1)
270
+ })
271
+ })
272
+
273
+ describe('sendReplyChunks — preview edit-in-place', () => {
274
+ it('edits the stale preview on the first chunk, consumes it, then sends the rest fresh', async () => {
275
+ const { deps, calls } = makeFake()
276
+ const state = baseState({ chunks: ['a', 'b'], previewMessageId: 900 })
277
+ const res = await sendReplyChunks(deps, state)
278
+ expect(calls[0].kind).toBe('editPreview')
279
+ expect(calls[0].messageId).toBe(900)
280
+ // preview id consumed → subsequent chunk is a fresh send
281
+ expect(calls[1].kind).toBe('sendRich')
282
+ expect(res.previewMessageId).toBeNull()
283
+ // first sentId is the reused preview id
284
+ expect(state.sentIds[0]).toBe(900)
285
+ })
286
+
287
+ it('a failed preview edit deletes the stale preview and sends fresh', async () => {
288
+ const { deps, calls, deleted } = makeFake({ failNext: { editPreview: [new Error('message to edit not found')] } })
289
+ const state = baseState({ chunks: ['a'], previewMessageId: 901 })
290
+ const res = await sendReplyChunks(deps, state)
291
+ expect(deleted).toEqual([901])
292
+ expect(calls.some((c) => c.kind === 'sendRich')).toBe(true)
293
+ expect(res.previewMessageId).toBeNull()
294
+ })
295
+
296
+ it('a "not modified" preview edit is treated as success (id reused, no fresh send)', async () => {
297
+ const { deps, calls, deleted } = makeFake({ failNext: { editPreview: [new Error('Bad Request: message is not modified')] } })
298
+ const state = baseState({ chunks: ['a'], previewMessageId: 902 })
299
+ await sendReplyChunks(deps, state)
300
+ expect(deleted).toEqual([])
301
+ expect(state.sentIds).toEqual([902])
302
+ expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(0)
303
+ })
304
+ })
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ repairEscapedWhitespace,
4
+ normalizeParagraphBreaks,
5
+ normalizePunctuation,
6
+ stripExcessBold,
7
+ addParagraphSpacers,
8
+ splitMarkdownChunks,
9
+ hardSliceToCap,
10
+ RICH_MESSAGE_MAX_CHARS,
11
+ } from '../format.js'
12
+ import { scrubVoice } from '../text-voice-scrub.js'
13
+ import { redact } from '../secret-detect/redact.js'
14
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
15
+ import {
16
+ normalizeOutboundBody,
17
+ computeEffectiveText,
18
+ computeReplyChunks,
19
+ resplitOversizeChunk,
20
+ chunkText,
21
+ } from '../gateway/outbound-send-path.js'
22
+
23
+ /**
24
+ * Golden-transcript harness for the outbound send-path extraction (#2996,
25
+ * plan §3B).
26
+ *
27
+ * `executeReply` is not exported (same constraint the sibling
28
+ * gateway-outbound-redact.test.ts documents), so the golden reference is a
29
+ * VERBATIM inline copy of the pre-extraction pipeline transforms (below).
30
+ * Every fixture is run through BOTH the inline reference and the extracted
31
+ * `outbound-send-path.ts` module; the test asserts byte-identical output.
32
+ * If the extraction ever drifts from the inline pipeline, these diverge.
33
+ *
34
+ * The reference below is copied line-for-line from the executeReply entry
35
+ * (normalize → redact → punctuation/bold → voice scrub), the effective-text
36
+ * spacing decision, the chunk decision, and the oversize re-split. The
37
+ * `redact` injected here is the same `redact()` the gateway's
38
+ * `redactOutboundText` wraps.
39
+ */
40
+
41
+ // ── Verbatim inline reference (the code as it lived in gateway.ts) ──────────
42
+
43
+ function referenceNormalize(rawText: string): { text: string; voiceReplaced: number } {
44
+ let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
45
+ // redactOutboundText(text, 'reply') → redact(text) (the stderr log is a
46
+ // side effect the pure module leaves to the injected redactor).
47
+ text = redact(text)
48
+ text = stripExcessBold(normalizePunctuation(text))
49
+ let voiceReplaced = 0
50
+ const scrub = scrubVoice(text)
51
+ if (scrub.replaced > 0) {
52
+ text = scrub.scrubbed
53
+ voiceReplaced = scrub.replaced
54
+ }
55
+ return { text, voiceReplaced }
56
+ }
57
+
58
+ function referenceEffectiveText(text: string, literalText: boolean): string {
59
+ return literalText ? text : addParagraphSpacers(text)
60
+ }
61
+
62
+ function referenceChunks(
63
+ effectiveText: string,
64
+ literalText: boolean,
65
+ limit: number,
66
+ chunkMode: 'length' | 'newline',
67
+ ): string[] {
68
+ return literalText
69
+ ? chunkText(effectiveText, limit, chunkMode)
70
+ : splitMarkdownChunks(effectiveText, limit)
71
+ }
72
+
73
+ function referenceResplit(chunk: string): string[] {
74
+ const subPieces = splitMarkdownChunks(chunk, RICH_MESSAGE_MAX_CHARS)
75
+ return subPieces.length > 1 ? subPieces : hardSliceToCap(chunk, RICH_MESSAGE_MAX_CHARS)
76
+ }
77
+
78
+ const injectedRedact = (text: string, _site: string): string => redact(text)
79
+
80
+ // ── Representative outbound fixtures ────────────────────────────────────────
81
+
82
+ const FIXTURES: Record<string, string> = {
83
+ plain: 'Hello there, this is a normal reply.',
84
+ multiParagraph: 'First paragraph here.\n\nSecond paragraph here.\n\nThird one.',
85
+ codeFence:
86
+ 'Here is code:\n\n```ts\nconst x = 1 // a comment — with an em-dash\nconst y = 2\n```\n\nDone.',
87
+ emDashes: 'This — that — and the other thing. En–dash here too.',
88
+ excessBold: '**bold one** and **bold two** and **bold three** and **bold four**.',
89
+ // Assembled at runtime so the source holds no contiguous token literal
90
+ // (scripts/check-no-pii-secrets.mjs rejects contiguous sk-ant-… literals).
91
+ secretApiKey: `Your key is ${'sk-ant-' + 'api03-' + 'ABCD'.repeat(12)} and more text.`,
92
+ jsonEscaped: 'Line one\\nLine two\\n\\nParagraph two with a \\t tab.',
93
+ bullets: '- item one\n- item two\n- item three with a — dash',
94
+ oversize: 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 5000),
95
+ multiChunkProse: Array.from({ length: 60 }, (_, i) => `Paragraph number ${i} with some filler text to grow length.`).join('\n\n'),
96
+ }
97
+
98
+ describe('outbound-send-path — normalizeOutboundBody parity with inline pipeline', () => {
99
+ for (const [name, raw] of Object.entries(FIXTURES)) {
100
+ it(`normalize byte-identical: ${name}`, () => {
101
+ const ref = referenceNormalize(raw)
102
+ const got = normalizeOutboundBody(raw, 'reply', injectedRedact)
103
+ expect(got.text).toBe(ref.text)
104
+ expect(got.voiceReplaced).toBe(ref.voiceReplaced)
105
+ })
106
+ }
107
+
108
+ it('secret fixture is actually redacted (mask fired)', () => {
109
+ const got = normalizeOutboundBody(FIXTURES.secretApiKey, 'reply', injectedRedact)
110
+ expect(got.text).not.toContain('sk-ant-' + 'api03-' + 'ABCD'.repeat(12))
111
+ })
112
+
113
+ it('em/en dashes are removed by the normalize pipeline', () => {
114
+ const got = normalizeOutboundBody(FIXTURES.emDashes, 'reply', injectedRedact)
115
+ // normalizePunctuation + scrubVoice between them strip em/en dashes from
116
+ // prose; the exact stage that fires is an implementation detail, but no
117
+ // raw em-dash survives the pipeline.
118
+ expect(got.text).not.toContain('—')
119
+ })
120
+ })
121
+
122
+ describe('outbound-send-path — effective text + chunk parity', () => {
123
+ for (const [name, raw] of Object.entries(FIXTURES)) {
124
+ for (const literalText of [true, false]) {
125
+ it(`effectiveText + chunks byte-identical: ${name} literal=${literalText}`, () => {
126
+ const { text } = normalizeOutboundBody(raw, 'reply', injectedRedact)
127
+ const refEff = referenceEffectiveText(text, literalText)
128
+ const gotEff = computeEffectiveText(text, literalText)
129
+ expect(gotEff).toBe(refEff)
130
+
131
+ const limit = RICH_MESSAGE_MAX_CHARS
132
+ const chunkMode: 'length' | 'newline' = 'length'
133
+ const refChunks = referenceChunks(refEff, literalText, limit, chunkMode)
134
+ const gotChunks = computeReplyChunks({ effectiveText: gotEff, literalText, limit, chunkMode })
135
+ expect(gotChunks).toEqual(refChunks)
136
+ })
137
+ }
138
+ }
139
+
140
+ it('literal newline-mode chunking parity on a large body', () => {
141
+ const body = FIXTURES.multiChunkProse
142
+ const limit = 400
143
+ expect(computeReplyChunks({ effectiveText: body, literalText: true, limit, chunkMode: 'newline' }))
144
+ .toEqual(chunkText(body, limit, 'newline'))
145
+ })
146
+
147
+ it('oversize chunk re-split parity + every piece under the wire cap', () => {
148
+ const oversize = FIXTURES.oversize
149
+ const ref = referenceResplit(oversize)
150
+ const got = resplitOversizeChunk(oversize)
151
+ expect(got).toEqual(ref)
152
+ for (const piece of got) expect(piece.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
153
+ })
154
+ })
155
+
156
+ describe('outbound-send-path — chunkText golden snapshots', () => {
157
+ it('length mode hard-cuts at the limit', () => {
158
+ expect(chunkText('abcdefghij', 4, 'length')).toEqual(['abcd', 'efgh', 'ij'])
159
+ })
160
+ it('newline mode prefers a paragraph break past halfway', () => {
161
+ expect(chunkText('aaaa\n\nbbbbbbbb', 8, 'newline')).toEqual(['aaaa\n', 'bbbbbbbb'])
162
+ })
163
+ it('short text is returned whole', () => {
164
+ expect(chunkText('short', 100, 'length')).toEqual(['short'])
165
+ })
166
+ })
167
+
168
+ // ── Cross-surface dedup suppression (the load-bearing singleton contract) ────
169
+ //
170
+ // The plan calls out that `outboundDedup` MUST be the SAME injected instance
171
+ // across executeReply / answer-stream / turn-flush — a hoist-once bug (the
172
+ // gateway landmine comment) broke all three. The dedup KEY on every surface is
173
+ // the post-`normalizeOutboundBody` text, so a stream-path record must suppress
174
+ // a reply-path check for the same normalized content on the same singleton.
175
+
176
+ describe('outbound-send-path — cross-surface dedup suppression', () => {
177
+ it('stream-path record suppresses reply-path check on the shared singleton', () => {
178
+ const dedup = new OutboundDedupCache()
179
+ const chatId = '12345'
180
+ const threadId = undefined
181
+ const turnKey = 'turn-abc'
182
+ const t0 = 1_000_000
183
+
184
+ // Both surfaces normalize the same raw model text identically.
185
+ const rawFromModel = 'The answer is 42 — computed carefully across the whole set of inputs.'
186
+ const streamText = normalizeOutboundBody(rawFromModel, 'stream_reply', injectedRedact).text
187
+ const replyText = normalizeOutboundBody(rawFromModel, 'reply', injectedRedact).text
188
+ expect(replyText).toBe(streamText) // same key on every surface
189
+
190
+ // Miss before anything recorded.
191
+ expect(dedup.check(chatId, threadId, replyText, t0, turnKey)).toBeNull()
192
+
193
+ // Answer-stream records first (same singleton, same turnKey).
194
+ dedup.record(chatId, threadId, streamText, t0, turnKey)
195
+
196
+ // Reply path now sees the within-turn duplicate and is suppressed.
197
+ const hit = dedup.check(chatId, threadId, replyText, t0 + 500, turnKey)
198
+ expect(hit).not.toBeNull()
199
+ expect(hit!.matched).toBe(true)
200
+ })
201
+
202
+ it('a DISTINCT normalized reply is NOT suppressed (no false dedup)', () => {
203
+ const dedup = new OutboundDedupCache()
204
+ const chatId = '12345'
205
+ const turnKey = 'turn-abc'
206
+ const t0 = 2_000_000
207
+ const a = normalizeOutboundBody('First distinct answer with enough length to record.', 'stream_reply', injectedRedact).text
208
+ const b = normalizeOutboundBody('Second completely different answer, also long enough.', 'reply', injectedRedact).text
209
+ dedup.record(chatId, undefined, a, t0, turnKey)
210
+ expect(dedup.check(chatId, undefined, b, t0 + 500, turnKey)).toBeNull()
211
+ })
212
+
213
+ it('cross-turn identical content is NOT suppressed (distinct turnKeys)', () => {
214
+ const dedup = new OutboundDedupCache()
215
+ const chatId = '12345'
216
+ const t0 = 3_000_000
217
+ const text = normalizeOutboundBody('Same content typed twice across two turns, long enough to record.', 'reply', injectedRedact).text
218
+ dedup.record(chatId, undefined, text, t0, 'turn-1')
219
+ // A later, separate turn with the same content still delivers.
220
+ expect(dedup.check(chatId, undefined, text, t0 + 500, 'turn-2')).toBeNull()
221
+ })
222
+ })
@@ -20,6 +20,16 @@ import { dirname, resolve } from 'node:path'
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url))
21
21
  const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
22
22
  const GATEWAY = read('gateway/gateway.ts')
23
+ // #2996 Phase 3: the in-memory approval-card Maps + their TTL sweeps moved
24
+ // behind approval-card-stores.ts. The gateway now delegates each family sweep
25
+ // to `<store>.sweep(now)`; the per-entry sweepExpiredEntries guard lives in the
26
+ // store module.
27
+ const CARD_STORES = read('gateway/approval-card-stores.ts')
28
+ // #2996 Phase 5: the callback-query handler families (vault access/save,
29
+ // mental-model, deferred-secret, grant wizard, operator-event, auth dashboard)
30
+ // moved verbatim behind callback-query-handlers.ts; handler-body pins read
31
+ // that module while staging/boot/sweep wiring stays pinned on gateway.ts.
32
+ const CB_HANDLERS = read('gateway/callback-query-handlers.ts')
23
33
 
24
34
  function slice(src: string, header: string, span = 3000): string {
25
35
  const start = src.indexOf(header)
@@ -90,7 +100,7 @@ describe('boot restore (Defect A)', () => {
90
100
  })
91
101
 
92
102
  it('a Save tap on a restored (valueless) card degrades gracefully instead of writing empty', () => {
93
- const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 9000)
103
+ const fn = slice(CB_HANDLERS, 'async function handleVaultRequestSaveCallback', 9000)
94
104
  expect(fn).toMatch(/pending\.restoredWithoutValue \|\| pending\.value\.length === 0/)
95
105
  expect(fn).toMatch(/lost to a gateway restart/)
96
106
  expect(fn).toMatch(/buildVaultSaveFailedInbound/)
@@ -102,15 +112,15 @@ describe('boot restore (Defect A)', () => {
102
112
  describe('resolution clears the durable store (Defect A)', () => {
103
113
  it('vault access approve/deny remove from the store', () => {
104
114
  // deny path
105
- const deny = slice(GATEWAY, 'async function handleVaultRequestAccessCallback', 4000)
115
+ const deny = slice(CB_HANDLERS, 'async function handleVaultRequestAccessCallback', 4000)
106
116
  expect(deny).toMatch(/pendingCardStore\.remove\(stageId\)/)
107
117
  // approve path (performVaultAccessApproval) removes on every terminal branch
108
- const approve = slice(GATEWAY, 'async function performVaultAccessApproval', 9000)
118
+ const approve = slice(CB_HANDLERS, 'async function performVaultAccessApproval', 9000)
109
119
  expect(approve).toMatch(/pendingCardStore\.remove\(stageId\)/)
110
120
  })
111
121
 
112
122
  it('vault save resolution paths remove from the store', () => {
113
- const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 12000)
123
+ const fn = slice(CB_HANDLERS, 'async function handleVaultRequestSaveCallback', 12000)
114
124
  // discard / write-fail / success / passphrase-missing all clear the store.
115
125
  const count = (fn.match(/pendingCardStore\.remove\(stageId\)/g) ?? []).length
116
126
  expect(count).toBeGreaterThanOrEqual(3)
@@ -124,7 +134,7 @@ describe('resolution clears the durable store (Defect A)', () => {
124
134
  })
125
135
 
126
136
  it('mental model resolve removes from the store', () => {
127
- const fn = slice(GATEWAY, 'async function handleMentalModelProposeCallback', 4000)
137
+ const fn = slice(CB_HANDLERS, 'async function handleMentalModelProposeCallback', 4000)
128
138
  expect(fn).toMatch(/pendingCardStore\.remove\(stageId\)/)
129
139
  })
130
140
  })
@@ -141,10 +151,13 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
141
151
  })
142
152
 
143
153
  it('sweepExpiredApprovalCards runs all four family sweeps', () => {
154
+ // #2996 Phase 3: three families now delegate to their store's `.sweep(now)`;
155
+ // request_secret still routes via sweepSecretRequests (which also sweeps the
156
+ // transient armedSecretCaptures) and that in turn calls the store sweep.
144
157
  const fn = slice(GATEWAY, 'function sweepExpiredApprovalCards', 600)
145
- expect(fn).toMatch(/sweepPendingVaultRequestAccesses\(now\)/)
146
- expect(fn).toMatch(/sweepPendingVaultRequestSaves\(now\)/)
147
- expect(fn).toMatch(/sweepPendingMentalModelProposes\(now\)/)
158
+ expect(fn).toMatch(/pendingVaultRequestAccesses\.sweep\(now\)/)
159
+ expect(fn).toMatch(/pendingVaultRequestSaves\.sweep\(now\)/)
160
+ expect(fn).toMatch(/pendingMentalModelProposes\.sweep\(now\)/)
148
161
  expect(fn).toMatch(/sweepSecretRequests\(now\)/)
149
162
  })
150
163
 
@@ -173,14 +186,20 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
173
186
  })
174
187
 
175
188
  it('each family sweep is per-entry guarded via sweepExpiredEntries', () => {
176
- for (const fnName of [
177
- 'function sweepPendingVaultRequestAccesses',
178
- 'function sweepPendingVaultRequestSaves',
179
- 'function sweepPendingMentalModelProposes',
180
- 'function sweepSecretRequests',
189
+ // #2996 Phase 3: the per-entry guard moved into the store module's `.sweep`,
190
+ // which is a thin pass-through to the same pure sweepExpiredEntries core.
191
+ // The three vault/mental families delegate to that store method; the
192
+ // request_secret family's sweepSecretRequests delegates to it too.
193
+ expect(CARD_STORES).toMatch(/sweep:\s*\(now\)\s*=>\s*\n?\s*sweepExpiredEntries\(/)
194
+ const secretSweep = slice(GATEWAY, 'function sweepSecretRequests', 500)
195
+ expect(secretSweep).toMatch(/pendingSecretRequests\.sweep\(now\)/)
196
+ for (const store of [
197
+ 'const pendingVaultRequestAccesses = createSweepableCardStore',
198
+ 'const pendingVaultRequestSaves = createSweepableCardStore',
199
+ 'const pendingMentalModelProposes = createSweepableCardStore',
200
+ 'const pendingSecretRequests = createSweepableCardStore',
181
201
  ]) {
182
- const fn = slice(GATEWAY, fnName, 500)
183
- expect(fn, fnName).toMatch(/sweepExpiredEntries\(/)
202
+ expect(GATEWAY, store).toContain(store)
184
203
  }
185
204
  })
186
205