switchroom 0.19.40 → 0.19.41

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.
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Outcome pins for checklist graceful degradation
3
+ * (gateway/checklist-fallback.ts).
4
+ *
5
+ * The bug this guards: `send_checklist` built a FLAT
6
+ * `{ chat_id, title, tasks: [{ text, is_completed }] }` payload for
7
+ * Telegram's `sendChecklist`, which requires (a) `title`/`tasks` nested
8
+ * inside a single `checklist` object, (b) a REQUIRED integer `id` per task,
9
+ * (c) NO completion flag, and (d) a `business_connection_id` — native
10
+ * checklists are Business-account-only, so every ordinary bot chat got a raw
11
+ * `400: Bad Request: parameter "checklist" is required`.
12
+ *
13
+ * Pinned outcomes: the normal (no business connection) path sends a
14
+ * formatted ✅/⬜ text message and reports `degraded: "text"`; the native
15
+ * payload, when built, is CORRECT per Bot API 9.1; and no native failure
16
+ * escapes as a raw error — it falls back to text.
17
+ */
18
+
19
+ import { describe, it, expect, vi } from 'vitest'
20
+ import {
21
+ buildChecklistTasks,
22
+ renderChecklistText,
23
+ applyChecklistPatch,
24
+ buildNativeChecklistPayload,
25
+ buildNativeEditChecklistPayload,
26
+ createChecklistStore,
27
+ checklistStoreKey,
28
+ performSendChecklist,
29
+ performUpdateChecklist,
30
+ sendChecklistToolText,
31
+ updateChecklistToolText,
32
+ CHECKLIST_MAX_TASKS,
33
+ type ChecklistState,
34
+ type SendChecklistDeps,
35
+ type UpdateChecklistDeps,
36
+ } from '../gateway/checklist-fallback.js'
37
+
38
+ function sendDeps(over: Partial<SendChecklistDeps> = {}) {
39
+ const sendNative = vi.fn(async () => ({ message_id: 100 }))
40
+ const sendText = vi.fn(async () => ({ message_id: 200 }))
41
+ const log = vi.fn()
42
+ const deps: SendChecklistDeps = {
43
+ businessConnectionId: undefined,
44
+ nativeAvailable: true,
45
+ sendNative,
46
+ sendText,
47
+ literalText: false,
48
+ log,
49
+ ...over,
50
+ }
51
+ return { deps, sendNative, sendText, log }
52
+ }
53
+
54
+ function updateDeps(over: Partial<UpdateChecklistDeps> = {}) {
55
+ const editNative = vi.fn(async () => {})
56
+ const editText = vi.fn(async () => {})
57
+ const log = vi.fn()
58
+ const deps: UpdateChecklistDeps = {
59
+ state: undefined,
60
+ businessConnectionId: undefined,
61
+ nativeAvailable: true,
62
+ editNative,
63
+ editText,
64
+ literalText: false,
65
+ log,
66
+ chatId: 42,
67
+ messageId: 7,
68
+ ...over,
69
+ }
70
+ return { deps, editNative, editText, log }
71
+ }
72
+
73
+ const textState = (over: Partial<ChecklistState> = {}): ChecklistState => ({
74
+ title: 'Trip prep',
75
+ tasks: [
76
+ { id: 1, text: 'book flights', done: false },
77
+ { id: 2, text: 'renew passport', done: true },
78
+ ],
79
+ mode: 'text',
80
+ ...over,
81
+ })
82
+
83
+ describe('buildChecklistTasks', () => {
84
+ it('assigns sequential 1-based ids and normalizes done', () => {
85
+ expect(buildChecklistTasks([{ text: 'a' }, { text: 'b', done: true }])).toEqual([
86
+ { id: 1, text: 'a', done: false },
87
+ { id: 2, text: 'b', done: true },
88
+ ])
89
+ })
90
+
91
+ it('rejects more than the 30-task API limit', () => {
92
+ const tasks = Array.from({ length: CHECKLIST_MAX_TASKS + 1 }, (_, i) => ({ text: `t${i}` }))
93
+ expect(() => buildChecklistTasks(tasks)).toThrow(/30-task limit/)
94
+ })
95
+ })
96
+
97
+ describe('renderChecklistText', () => {
98
+ it('renders bold title + ✅/⬜ lines honoring done', () => {
99
+ expect(renderChecklistText(textState())).toBe('**Trip prep**\n⬜ book flights\n✅ renew passport')
100
+ })
101
+
102
+ it('literal mode drops the markdown bold (parseMode: text sends are not parsed)', () => {
103
+ expect(renderChecklistText(textState(), { literal: true })).toBe('Trip prep\n⬜ book flights\n✅ renew passport')
104
+ })
105
+ })
106
+
107
+ describe('applyChecklistPatch', () => {
108
+ it('updates by id, appends without id, replaces title; input untouched', () => {
109
+ const base = textState()
110
+ const next = applyChecklistPatch(base, {
111
+ title: 'Trip prep v2',
112
+ tasks: [{ id: '1', done: true }, { text: 'pack bags' }],
113
+ })
114
+ expect(next).toEqual({
115
+ title: 'Trip prep v2',
116
+ tasks: [
117
+ { id: 1, text: 'book flights', done: true },
118
+ { id: 2, text: 'renew passport', done: true },
119
+ { id: 3, text: 'pack bags', done: false },
120
+ ],
121
+ })
122
+ expect(base.tasks[0].done).toBe(false) // pure — no mutation
123
+ })
124
+
125
+ it('appends a task with an unknown id, preserving that id', () => {
126
+ const next = applyChecklistPatch(textState(), { tasks: [{ id: 9, text: 'buy sim', done: false }] })
127
+ expect(next.tasks[2]).toEqual({ id: 9, text: 'buy sim', done: false })
128
+ })
129
+ })
130
+
131
+ describe('native payloads — correct Bot API 9.1 shape', () => {
132
+ it('sendChecklist nests title/tasks inside `checklist`, carries ids, has NO is_completed', () => {
133
+ const payload = buildNativeChecklistPayload({
134
+ businessConnectionId: 'bc1',
135
+ chatId: 42,
136
+ title: 'T',
137
+ tasks: buildChecklistTasks([{ text: 'a', done: true }, { text: 'b' }]),
138
+ replyToMessageId: 5,
139
+ })
140
+ expect(payload).toEqual({
141
+ business_connection_id: 'bc1',
142
+ chat_id: 42,
143
+ checklist: { title: 'T', tasks: [{ id: 1, text: 'a' }, { id: 2, text: 'b' }] },
144
+ reply_parameters: { message_id: 5 },
145
+ })
146
+ expect(JSON.stringify(payload)).not.toContain('is_completed')
147
+ })
148
+
149
+ it('editMessageChecklist sends the FULL checklist (native edits replace it)', () => {
150
+ const payload = buildNativeEditChecklistPayload({
151
+ businessConnectionId: 'bc1',
152
+ chatId: 42,
153
+ messageId: 7,
154
+ title: 'T',
155
+ tasks: [{ id: 3, text: 'kept', done: false }],
156
+ })
157
+ expect(payload).toEqual({
158
+ business_connection_id: 'bc1',
159
+ chat_id: 42,
160
+ message_id: 7,
161
+ checklist: { title: 'T', tasks: [{ id: 3, text: 'kept' }] },
162
+ })
163
+ })
164
+ })
165
+
166
+ describe('performSendChecklist — degradation outcomes', () => {
167
+ it('no business connection (the fleet norm) → sends the ✅/⬜ text render, never native', async () => {
168
+ const { deps, sendNative, sendText } = sendDeps()
169
+ const r = await performSendChecklist(deps, {
170
+ title: 'Trip prep',
171
+ tasks: [{ text: 'book flights' }, { text: 'renew passport', done: true }],
172
+ chatId: 42,
173
+ })
174
+ expect(sendNative).not.toHaveBeenCalled()
175
+ expect(sendText).toHaveBeenCalledWith('**Trip prep**\n⬜ book flights\n✅ renew passport')
176
+ expect(r).toMatchObject({ message_id: 200, mode: 'text' })
177
+ expect(r.state).toEqual({
178
+ title: 'Trip prep',
179
+ tasks: [
180
+ { id: 1, text: 'book flights', done: false },
181
+ { id: 2, text: 'renew passport', done: true },
182
+ ],
183
+ mode: 'text',
184
+ })
185
+ })
186
+
187
+ it('business connection configured → sends the correct nested native payload', async () => {
188
+ const { deps, sendNative, sendText } = sendDeps({ businessConnectionId: 'bc1' })
189
+ const r = await performSendChecklist(deps, { title: 'T', tasks: [{ text: 'a' }], chatId: 42 })
190
+ expect(sendText).not.toHaveBeenCalled()
191
+ expect(sendNative).toHaveBeenCalledWith({
192
+ business_connection_id: 'bc1',
193
+ chat_id: 42,
194
+ checklist: { title: 'T', tasks: [{ id: 1, text: 'a' }] },
195
+ })
196
+ expect(r).toMatchObject({ message_id: 100, mode: 'native' })
197
+ })
198
+
199
+ it('native failure does NOT escape — falls back to the text render', async () => {
200
+ const { deps, sendText, log } = sendDeps({
201
+ businessConnectionId: 'bc1',
202
+ sendNative: vi.fn(async () => { throw new Error('400: Bad Request: parameter "checklist" is required') }),
203
+ })
204
+ const r = await performSendChecklist(deps, { title: 'T', tasks: [{ text: 'a' }], chatId: 42 })
205
+ expect(r).toMatchObject({ message_id: 200, mode: 'text' })
206
+ expect(sendText).toHaveBeenCalledWith('**T**\n⬜ a')
207
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('falling back to text'))
208
+ })
209
+
210
+ it('checklist API not available in this grammY version → text render, no native attempt', async () => {
211
+ const { deps, sendNative } = sendDeps({ businessConnectionId: 'bc1', nativeAvailable: false })
212
+ const r = await performSendChecklist(deps, { title: 'T', tasks: [{ text: 'a' }], chatId: 42 })
213
+ expect(sendNative).not.toHaveBeenCalled()
214
+ expect(r.mode).toBe('text')
215
+ })
216
+ })
217
+
218
+ describe('performUpdateChecklist — degradation outcomes', () => {
219
+ it('text-mode: patch applies to stored state and re-renders the ✅/⬜ text', async () => {
220
+ const { deps, editText, editNative } = updateDeps({ state: textState() })
221
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
222
+ expect(editNative).not.toHaveBeenCalled()
223
+ expect(editText).toHaveBeenCalledWith('**Trip prep**\n✅ book flights\n✅ renew passport')
224
+ expect(r).toMatchObject({ ok: true, mode: 'text' })
225
+ })
226
+
227
+ it('no stored state + partial patch → structured unknown_checklist, no edit attempted', async () => {
228
+ const { deps, editText, editNative } = updateDeps()
229
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
230
+ expect(r).toMatchObject({ ok: false, reason: 'unknown_checklist' })
231
+ expect(editText).not.toHaveBeenCalled()
232
+ expect(editNative).not.toHaveBeenCalled()
233
+ })
234
+
235
+ it('no stored state + full replacement (title + all task texts) → rebuilds and edits as text', async () => {
236
+ const { deps, editText } = updateDeps()
237
+ const r = await performUpdateChecklist(deps, {
238
+ title: 'Rebuilt',
239
+ tasks: [{ text: 'a', done: true }, { text: 'b' }],
240
+ })
241
+ expect(r).toMatchObject({ ok: true, mode: 'text' })
242
+ expect(editText).toHaveBeenCalledWith('**Rebuilt**\n✅ a\n⬜ b')
243
+ })
244
+
245
+ it('native-mode: edits with the FULL post-patch checklist payload', async () => {
246
+ const { deps, editNative } = updateDeps({
247
+ state: textState({ mode: 'native' }),
248
+ businessConnectionId: 'bc1',
249
+ })
250
+ const r = await performUpdateChecklist(deps, { tasks: [{ text: 'new task' }] })
251
+ expect(r).toMatchObject({ ok: true, mode: 'native' })
252
+ expect(editNative).toHaveBeenCalledWith({
253
+ business_connection_id: 'bc1',
254
+ chat_id: 42,
255
+ message_id: 7,
256
+ checklist: {
257
+ title: 'Trip prep',
258
+ tasks: [
259
+ { id: 1, text: 'book flights' },
260
+ { id: 2, text: 'renew passport' },
261
+ { id: 3, text: 'new task' },
262
+ ],
263
+ },
264
+ })
265
+ })
266
+
267
+ it('a failed edit never throws a raw Telegram error — structured edit_failed instead', async () => {
268
+ const { deps } = updateDeps({
269
+ state: textState(),
270
+ editText: vi.fn(async () => { throw new Error('400: Bad Request: message to edit not found') }),
271
+ })
272
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
273
+ expect(r).toMatchObject({ ok: false, reason: 'edit_failed' })
274
+ expect((r as { hint: string }).hint).toContain('message to edit not found')
275
+ })
276
+
277
+ it('native-mode checklist with the business connection gone → structured edit_failed', async () => {
278
+ const { deps, editNative } = updateDeps({ state: textState({ mode: 'native' }) })
279
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
280
+ expect(r).toMatchObject({ ok: false, reason: 'edit_failed' })
281
+ expect(editNative).not.toHaveBeenCalled()
282
+ })
283
+ })
284
+
285
+ describe('tool-result text', () => {
286
+ it('send: degraded text result carries degraded:"text" so the agent knows', () => {
287
+ const parsed = JSON.parse(sendChecklistToolText({
288
+ message_id: 200, mode: 'text', state: textState(),
289
+ }))
290
+ expect(parsed).toMatchObject({ ok: true, message_id: 200, mode: 'text', degraded: 'text' })
291
+ expect(parsed.note).toContain('Business connection')
292
+ })
293
+
294
+ it('send: native result has no degraded marker', () => {
295
+ expect(JSON.parse(sendChecklistToolText({
296
+ message_id: 100, mode: 'native', state: textState({ mode: 'native' }),
297
+ }))).toEqual({ ok: true, message_id: 100, mode: 'native' })
298
+ })
299
+
300
+ it('update: structured failure round-trips reason + hint', () => {
301
+ expect(JSON.parse(updateChecklistToolText(
302
+ { ok: false, reason: 'unknown_checklist', hint: 'resend it' }, 7,
303
+ ))).toEqual({ ok: false, reason: 'unknown_checklist', hint: 'resend it' })
304
+ })
305
+ })
306
+
307
+ describe('createChecklistStore', () => {
308
+ it('evicts oldest entries past the cap', () => {
309
+ const store = createChecklistStore(2)
310
+ store.set(checklistStoreKey('1', 1), textState())
311
+ store.set(checklistStoreKey('1', 2), textState())
312
+ store.set(checklistStoreKey('1', 3), textState())
313
+ expect(store.get('1:1')).toBeUndefined()
314
+ expect(store.get('1:2')).toBeDefined()
315
+ expect(store.get('1:3')).toBeDefined()
316
+ })
317
+ })
@@ -114,21 +114,25 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
114
114
  expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
115
115
  })
116
116
 
117
- it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
118
- // F2 — checklist title + task strings were sent unredacted.
117
+ it('send_checklist: redacts title + task text BEFORE the send orchestration', () => {
118
+ // F2 — checklist title + task strings were sent unredacted. The send now
119
+ // routes through performSendChecklist (native OR text fallback), so the
120
+ // scrub must land before that orchestration call — both paths receive
121
+ // only redacted strings.
119
122
  const start = src.indexOf('async function executeSendChecklist(')
120
123
  const redactIdx = src.indexOf('redactChecklistFields(', start)
121
- const sendIdx = src.indexOf('rawSendChecklist({', start)
124
+ const sendIdx = src.indexOf('performSendChecklist({', start)
122
125
  expect(start).toBeGreaterThan(0)
123
126
  expect(redactIdx).toBeGreaterThan(start)
124
127
  expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
125
128
  })
126
129
 
127
- it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
128
- // F2 sibling — update_checklist shares the identical leak class.
130
+ it('update_checklist: redacts title + task text BEFORE the edit orchestration', () => {
131
+ // F2 sibling — update_checklist shares the identical leak class; the edit
132
+ // routes through performUpdateChecklist (native OR text fallback).
129
133
  const start = src.indexOf('async function executeUpdateChecklist(')
130
134
  const redactIdx = src.indexOf('redactChecklistFields(', start)
131
- const editIdx = src.indexOf('rawEditMessageChecklist({', start)
135
+ const editIdx = src.indexOf('performUpdateChecklist({', start)
132
136
  expect(start).toBeGreaterThan(0)
133
137
  expect(redactIdx).toBeGreaterThan(start)
134
138
  expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import {
4
4
  computeTurnStatus,
5
+ computeTurnRoute,
5
6
  backstopSendOutcome,
6
7
  finalizeBackstopSend,
7
8
  buildTurnRecord,
@@ -39,6 +40,67 @@ describe('computeTurnStatus — recorded turn status reflects real outcome', ()
39
40
  })
40
41
  })
41
42
 
43
+ /**
44
+ * Honest delivery ROUTE. Derived from the SAME resolved delivery state
45
+ * `computeTurnStatus` reads, so the fleet-health detector can tell a
46
+ * flush-recovered turn (answer delivered by a backstop after the reply tool was
47
+ * bypassed) apart from a genuine silent no-op. These assert the mapping OUTCOME,
48
+ * not merely that a branch ran.
49
+ */
50
+ describe('computeTurnRoute — honest delivery route from resolved state', () => {
51
+ it('deliveryOutcome delivered → flush (backstop delivered the answer)', () => {
52
+ expect(
53
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: false, deliveryOutcome: 'delivered' }),
54
+ ).toBe('flush')
55
+ })
56
+
57
+ it('deliveryOutcome suppressed → reply (flush short-circuited; reply delivered)', () => {
58
+ expect(
59
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: true, deliveryOutcome: 'suppressed' }),
60
+ ).toBe('reply')
61
+ })
62
+
63
+ it('deliveryOutcome failed → none (nothing reached the user)', () => {
64
+ expect(
65
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: true, deliveryOutcome: 'failed' }),
66
+ ).toBe('none')
67
+ })
68
+
69
+ it('undefined outcome + finalAnswer + replyCalled → reply (synchronous reply tail)', () => {
70
+ expect(
71
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: true, deliveryOutcome: undefined }),
72
+ ).toBe('reply')
73
+ })
74
+
75
+ it('undefined outcome + finalAnswer + NOT replyCalled → stream', () => {
76
+ expect(
77
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: false, deliveryOutcome: undefined }),
78
+ ).toBe('stream')
79
+ })
80
+
81
+ it('undefined outcome + no final answer → none (genuine no-reply)', () => {
82
+ expect(
83
+ computeTurnRoute({ finalAnswerDelivered: false, replyCalled: false, deliveryOutcome: undefined }),
84
+ ).toBe('none')
85
+ })
86
+
87
+ it('buildTurnRecord stamps route on the row (flush for a delivered backstop send)', () => {
88
+ const rec = buildTurnRecord(
89
+ {
90
+ agent: 'test-agent',
91
+ startedAt: 1_700_000_495_000,
92
+ toolCallCount: 0,
93
+ turnId: 'turn-route',
94
+ finalAnswerDelivered: true,
95
+ replyCalled: false,
96
+ deliveryOutcome: 'delivered',
97
+ },
98
+ 1_700_000_500_000,
99
+ )
100
+ expect(rec.route).toBe('flush')
101
+ })
102
+ })
103
+
42
104
  describe('backstopSendOutcome — resolve outcome from what happened on the wire', () => {
43
105
  it('throw → failed', () => {
44
106
  expect(backstopSendOutcome({ threw: true, sentCount: 0, chunkCount: 1 })).toBe('failed')