packages/ai/components/workflows.test.ts

1import { describe, expect, test } from 'bun:test'
2import {
3	confirmedHandoff,
4	emailFromMessage,
5	HANDOFF_FAILURE_REPLY,
6	historyAfterHandoffCancellation,
7	isHandoffCancellation,
8	normalizeEmailEscapes,
9	resolveConversationEvent,
10	sessionConsent,
11} from './workflows'
12
13describe('conversational workflows', () => {
14	test('confirmation can name the destination team and include a polite closing', () => {
15		const history = [
16			{
17				role: 'assistant' as const,
18				text: 'Shall I send your enquiry to the UNLEASH team?',
19			},
20		]
21		expect(
22			confirmedHandoff(
23				history,
24				"Yes, please go ahead and send that enquiry to the UNLEASH team. I'm looking forward to hearing from them about next steps.",
25			),
26		).toBe(true)
27		expect(
28			confirmedHandoff(
29				history,
30				'Yes, please send it to our sponsorship team. Thank you for your help.',
31			),
32		).toBe(true)
33	})
34	test('trace consent replies progress only after the consent question', () => {
35		const history = [
36			{
37				role: 'assistant' as const,
38				text: 'Shall I send this request with your consent?',
39			},
40		]
41		for (const message of [
42			'yes pls',
43			'send it',
44			'Send the request.',
45			'Yes, please send that through for us.',
46			'Yes, I consent. Please send the enquiry.',
47			'I agree. Send my agenda.',
48			'I consent.',
49			'Yes, that all looks correct. Please send my enquiry to the UNLEASH team.',
50			'Yes, please send that enquiry through. That all looks correct to me.',
51			'Yes, please go ahead and send it.',
52			"Yes, that's correct. Please go ahead and send it.",
53			'Yes please send that enquiry through',
54			'Please email my agenda.',
55			'Go ahead and submit the enquiry, thanks.',
56		]) {
57			expect(confirmedHandoff(history, message)).toBe(true)
58			expect(sessionConsent(history, message)).toBe(true)
59			expect(
60				confirmedHandoff(
61					[
62						{
63							role: 'assistant',
64							text: 'Would you like our sales team to get in touch?',
65						},
66					],
67					message,
68				),
69			).toBe(false)
70		}
71		for (const message of [
72			'yes pls but later',
73			'send it if it is free',
74			'do not send it',
75			'Yes, please send that through for us if it is free',
76			'Yes, change the enquiry first',
77			'Yes, I consent. Please send the enquiry if it is free.',
78			'I agree. Actually change the email first.',
79			'Yes, please send it. I am looking forward to the event, but only if it is free.',
80		])
81			expect(confirmedHandoff(history, message)).toBe(false)
82	})
83	test('event comes from the page, with explicit conversational override', () => {
84		expect(
85			resolveConversationEvent(
86				'/events/unleash-miami',
87				'What about the unlesh pariss one?',
88				[],
89				'miami',
90			),
91		).toBe('paris')
92		expect(
93			resolveConversationEvent(
94				'/events/unleash-paris/agenda',
95				'Build my agenda',
96				[],
97			),
98		).toBe('paris')
99		expect(
100			resolveConversationEvent('/events/unleash-miami-27/attend', 'tickets?', []),
101		).toBe('miami')
102		expect(
103			resolveConversationEvent('/events/unleash-paris', 'Actually Miami', []),
104		).toBe('miami')
105		expect(resolveConversationEvent('/', 'tickets?', [])).toBeUndefined()
106		expect(
107			resolveConversationEvent('/', 'tickets?', [{ role: 'user', text: 'Paris' }]),
108		).toBe('paris')
109	})
110	test('sales acceptance is not submission consent', () => {
111		expect(
112			confirmedHandoff(
113				[
114					{
115						role: 'assistant',
116						text: 'Would you like our sales team to get in touch?',
117					},
118				],
119				'yes',
120			),
121		).toBe(false)
122	})
123	test('consent applies only to an affirmative reply to the confirmation', () => {
124		const history = [
125			{
126				role: 'assistant' as const,
127				text: 'Shall I send this request with your consent?',
128			},
129		]
130		expect(confirmedHandoff(history, 'yes please')).toBe(true)
131		expect(confirmedHandoff(history, 'not yet')).toBe(false)
132		expect(confirmedHandoff(history, 'change my email')).toBe(false)
133		expect(
134			confirmedHandoff(
135				[...history, { role: 'assistant', text: 'Anything else?' }],
136				'yes',
137			),
138		).toBe(false)
139	})
140})
141
142describe('session consent and email corrections', () => {
143	test('retains consent after an invalid address and a corrected address', () => {
144		const history = [
145			{
146				role: 'assistant' as const,
147				text: 'Shall I send this request with your consent?',
148			},
149			{ role: 'user' as const, text: 'yes please' },
150			{
151				role: 'assistant' as const,
152				text: 'The email address needs to be in a valid format.',
153			},
154		]
155		expect(sessionConsent(history, 'federico\\@itsoffbrand.com')).toBe(true)
156		expect(emailFromMessage('federico\\@itsoffbrand.com')).toBe(
157			'federico@itsoffbrand.com',
158		)
159		expect(emailFromMessage('yeah to federicoitsoffbrand.com')).toBeUndefined()
160	})
161	test('keeps the stored session decision after history is trimmed', () => {
162		expect(sessionConsent([], 'another question', true)).toBe(true)
163		expect(sessionConsent([], 'another question', false)).toBe(false)
164	})
165	test('allows consent to be withdrawn without confusing sales acceptance with consent', () => {
166		expect(sessionConsent([], 'I withdraw my consent', true)).toBe(false)
167		expect(
168			sessionConsent(
169				[
170					{
171						role: 'assistant',
172						text: 'Would you like our sales team to get in touch?',
173					},
174				],
175				'yes',
176			),
177		).toBe(false)
178	})
179	test('normalizes markdown escaping without guessing a missing at sign', () => {
180		expect(normalizeEmailEscapes('federico\\@itsoffbrand.com')).toBe(
181			'federico@itsoffbrand.com',
182		)
183		expect(normalizeEmailEscapes('federicoitsoffbrand.com')).toBe(
184			'federicoitsoffbrand.com',
185		)
186	})
187})
188
189describe('cancelling contact requests', () => {
190	const history = [
191		{
192			role: 'assistant' as const,
193			text:
194				'What company are you with, and what would you like to achieve through sponsoring or exhibiting?',
195		},
196	]
197	test('the exact staging cancellation stops the flow and withdraws stored consent', () => {
198		const message = 'Actually, cancel that. I do not want anyone to contact me.'
199		expect(isHandoffCancellation(history, message)).toBe(true)
200		expect(sessionConsent(history, message, true)).toBe(false)
201	})
202	test('short cancellation works at qualification and contact-detail steps', () => {
203		expect(
204			isHandoffCancellation(
205				[
206					{
207						role: 'assistant',
208						text: 'What company are you with, and what is your role?',
209					},
210				],
211				'No thanks',
212			),
213		).toBe(true)
214		expect(
215			isHandoffCancellation(
216				[{ role: 'assistant', text: 'How many tickets would you like to book?' }],
217				'Cancel that',
218			),
219		).toBe(true)
220		expect(isHandoffCancellation(history, 'Cancel that.')).toBe(true)
221		expect(
222			isHandoffCancellation(
223				[
224					{
225						role: 'assistant',
226						text:
227							'Who should our team contact? Please share your name and work email.',
228					},
229				],
230				'No thanks',
231			),
232		).toBe(true)
233	})
234	test('ordinary company details and ticket cancellation questions are not contact withdrawal', () => {
235		expect(isHandoffCancellation(history, 'Example Ltd, lead generation')).toBe(
236			false,
237		)
238		expect(isHandoffCancellation(history, 'How can I cancel my ticket?')).toBe(
239			false,
240		)
241		expect(isHandoffCancellation([], 'cancel that')).toBe(false)
242	})
243	test('old sales acceptance is cleared, while a later new request remains', () => {
244		const turns = [
245			{ role: 'user' as const, text: 'yes please' },
246			...history,
247			{ role: 'user' as const, text: 'Cancel that.' },
248			{ role: 'assistant' as const, text: 'Cancelled.' },
249			{ role: 'user' as const, text: 'A different question' },
250		]
251		expect(historyAfterHandoffCancellation(turns)).toEqual(turns.slice(3))
252	})
253})
254
255test('natural confirmation accepts send wording but rejects conditions and withdrawal', () => {
256	const history = [
257		{
258			role: 'assistant' as const,
259			text: 'Shall I send this request with your consent?',
260		},
261	]
262	for (const text of [
263		'yes please, send!',
264		'Yes, please go ahead and send it.',
265		'Yes, please send it. Thanks for your help!',
266		"Yes, please go ahead and send it. I'm looking forward to the event!",
267	])
268		expect(confirmedHandoff(history, text)).toBe(true)
269	for (const text of [
270		'yes but do not send it',
271		'yes if it is free',
272		'please send it tomorrow',
273		'no thanks',
274		'yes please cancel it',
275		'yes please. Thanks for not sending it',
276	])
277		expect(confirmedHandoff(history, text)).toBe(false)
278})
279
280test('failed handoffs do not reactivate earlier accepted sales offers', () => {
281	const history = [
282		{
283			role: 'assistant' as const,
284			text: 'Would you like our sales team to get in touch?',
285		},
286		{ role: 'user' as const, text: 'yes' },
287		{ role: 'assistant' as const, text: HANDOFF_FAILURE_REPLY },
288		{ role: 'user' as const, text: 'thanks' },
289	]
290	expect(historyAfterHandoffCancellation(history)).toEqual([history[3]])
291})
292
293test('natural send confirmations pass but conditions and unrelated requests do not', () => {
294	const history = [
295		{
296			role: 'assistant' as const,
297			text: 'Shall I send this request with your consent?',
298		},
299	]
300	for (const text of [
301		'Yes, please send it through.',
302		"Yes, that's all correct. Please send it.",
303		'Yes, absolutely — please send it.',
304		'Yes, absolutely - please send it to my email',
305	]) {
306		expect(confirmedHandoff(history, text.replace(' — ', ', '))).toBe(true)
307	}
308	for (const text of [
309		'thanks',
310		'Yes, what are the prices?',
311		'Yes please send it tomorrow',
312		'yes but only if free',
313		'Yes. Change my email first.',
314	])
315		expect(confirmedHandoff(history, text)).toBe(false)
316})
317
318test('approval of the reviewed summary with send instruction is confirmation, not another sales offer', () => {
319	const history = [
320		{
321			role: 'assistant' as const,
322			text: 'Shall I send your enquiry to our sponsorship team?',
323		},
324	]
325	for (const message of [
326		'That looks perfect, yes please send it through.',
327		"Yes, please go ahead and send that to the UNLEASH team. I'm looking forward to hearing from them about the Diamond Badge.",
328		'Yes, that all looks correct. Please go ahead and send my enquiry to your sponsorship team.',
329		'Yes, please send it through.',
330	])
331		expect(confirmedHandoff(history, message)).toBe(true)
332	for (const message of [
333		'That looks perfect, yes please send it if it is free.',
334		'That looks perfect, please change my email.',
335		'Yes, but do not send it.',
336	])
337		expect(confirmedHandoff(history, message)).toBe(false)
338})
339
340test('group quote confirmation accepts explicit consent but never conditions or field changes', () => {
341	const history = [
342		{
343			role: 'assistant' as const,
344			text:
345				'Your ticket enquiry for UNLEASH in Miami. Shall I send your enquiry to the UNLEASH team?',
346		},
347	]
348	expect(
349		confirmedHandoff(
350			history,
351			'Yes please, send this group quote enquiry with my consent.',
352		),
353	).toBe(true)
354	expect(
355		confirmedHandoff(
356			history,
357			'Yes please, send this group quote enquiry with my consent, if it is free.',
358		),
359	).toBe(false)
360	expect(
361		confirmedHandoff(
362			history,
363			'Send this group quote enquiry with my consent but change it to 500 tickets.',
364		),
365	).toBe(false)
366	expect(
367		confirmedHandoff(
368			[],
369			'Yes please, send this group quote enquiry with my consent.',
370		),
371	).toBe(false)
372})
373