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.
- package/README.md +2 -2
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +527 -2880
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(/
|
|
146
|
-
expect(fn).toMatch(/
|
|
147
|
-
expect(fn).toMatch(/
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
expect(fn, fnName).toMatch(/sweepExpiredEntries\(/)
|
|
202
|
+
expect(GATEWAY, store).toContain(store)
|
|
184
203
|
}
|
|
185
204
|
})
|
|
186
205
|
|