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.
- package/dist/cli/switchroom.js +39 -8
- package/dist/host-control/main.js +72 -17
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +240 -46
- package/telegram-plugin/dist/server.js +2 -2
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/gateway.ts +69 -69
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
|
@@ -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
|
|
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('
|
|
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
|
|
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('
|
|
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')
|