apps/web/src/lib/aiFlows.test.ts

1import { describe, expect, test } from 'bun:test'
2import {
3	AGENDA_INTEREST_QUESTION,
4	acceptedAgendaEmail,
5	acceptedSalesOffer,
6	asksRestrictedSessionAccess,
7	isCommercialInformationRequest,
8	isCommercialLogisticsQuestion,
9	isContentInformationRequest,
10	isConversationClosing,
11	isSelfServiceApplication,
12	isTicketInformationRequest,
13	isTicketSalesConversation,
14	needsAgendaInterests,
15	requestsTicketBooking,
16} from './aiFlows'
17
18test('eligibility and self-service applications do not enter contact collection', () => {
19	expect(isCommercialInformationRequest('Can I get an HR Leader pass?')).toBe(
20		true,
21	)
22	expect(
23		isCommercialInformationRequest('What sponsorship packages are available?'),
24	).toBe(true)
25	expect(
26		isCommercialInformationRequest(
27			'Can you contact me about sponsorship packages?',
28		),
29	).toBe(false)
30	expect(isCommercialInformationRequest('I want to buy two tickets')).toBe(false)
31	expect(
32		isCommercialInformationRequest(
33			'We are thinking 3 tickets. What are the pass types and pricing?',
34		),
35	).toBe(true)
36	expect(
37		isSelfServiceApplication(
38			"Thanks, I'll submit an application through that page then.",
39		),
40	).toBe(true)
41	expect(isSelfServiceApplication('Could you help me apply?')).toBe(false)
42})
43
44test('restricted session access questions cannot be satisfied by badge recommendations', () => {
45	expect(
46		asksRestrictedSessionAccess(
47			'Am I eligible for the invite-only CHRO Summit?',
48			[],
49		),
50	).toBe(true)
51	expect(
52		asksRestrictedSessionAccess('Would upgrading to Diamond give me access?', [
53			{ role: 'user', text: 'Can I attend the invitation-only summit?' },
54		]),
55	).toBe(true)
56	expect(
57		asksRestrictedSessionAccess(
58			'Build an agenda without invite-only sessions',
59			[],
60		),
61	).toBe(false)
62})
63
64test('polite closing does not become another flow offer or consent', () => {
65	expect(isConversationClosing("Thanks, that's all.")).toBe(true)
66	expect(isConversationClosing('Thank you for your help!')).toBe(true)
67	expect(isConversationClosing('Thanks, please send my enquiry.')).toBe(false)
68	expect(isConversationClosing('Yes please')).toBe(false)
69})
70
71test('content discovery cannot become ticket qualification', () => {
72	expect(
73		isContentInformationRequest(
74			"I'm wanting to find out more about the future of work.",
75		),
76	).toBe(true)
77	expect(isContentInformationRequest('Show me AI in HR articles')).toBe(true)
78	expect(isContentInformationRequest('I want to buy two tickets')).toBe(false)
79	expect(isContentInformationRequest('Build my AI agenda')).toBe(false)
80})
81
82test('agenda flow does not invent interests or reopen ticket qualification', () => {
83	expect(needsAgendaInterests([], 'can you build me an agenda?', [])).toBe(true)
84	expect(
85		needsAgendaInterests(
86			[{ role: 'assistant', text: AGENDA_INTEREST_QUESTION }],
87			'yes pleas',
88			[],
89		),
90	).toBe(true)
91	expect(needsAgendaInterests([], 'build me an agenda about AI', [])).toBe(false)
92	expect(
93		isTicketSalesConversation([
94			{ role: 'user', text: 'I want tickets' },
95			{ role: 'user', text: 'can you build me an agenda?' },
96		]),
97	).toBe(false)
98})
99
100test('short acceptance after a long plan advances to email', () => {
101	const history = [
102		{
103			role: 'assistant' as const,
104			text:
105				'Session details '.repeat(600) +
106				'Would you like me to email your selected agenda?',
107		},
108	]
109	for (const reply of ['yep', 'yes', 'yes please'])
110		expect(acceptedAgendaEmail(history, reply)).toBe(true)
111	expect(acceptedAgendaEmail(history, 'change the agenda')).toBe(false)
112})
113
114const offer = {
115	role: 'assistant' as const,
116	text:
117		'Options are on [Sponsor UNLEASH Paris 2026](/events/unleash-paris/exhibition/sponsorship-opportunities). Would you like our sales team to get in touch?',
118}
119
120describe('ticket sales routing', () => {
121	test('explicit buying intent starts capture, informational questions do not', () => {
122		for (const message of [
123			'I want to buy 8 tickets',
124			"I'd like to book a pass",
125			'We would like to buy tickets',
126			'Can you help me book tickets?',
127		]) {
128			expect(requestsTicketBooking(message)).toBe(true)
129		}
130		for (const message of [
131			'How much are tickets?',
132			'How do I buy tickets?',
133			'What passes are available?',
134			'I do not want to buy tickets',
135			'How do I cancel tickets?',
136		]) {
137			expect(requestsTicketBooking(message)).toBe(false)
138		}
139	})
140	test('recognises a ticket sales connection offer', () => {
141		expect(
142			acceptedSalesOffer(
143				[
144					{
145						role: 'assistant',
146						text: 'Would you like me to connect you with our ticket sales team?',
147					},
148				],
149				'yes please',
150			),
151		).toBe(true)
152	})
153	test('accepting ticket help does not enter sponsorship qualification', () => {
154		const history = [
155			{ role: 'user' as const, text: 'I want 8 tickets for my team' },
156			offer,
157		]
158		expect(acceptedSalesOffer(history, 'yes please')).toBe(true)
159		expect(isTicketSalesConversation(history)).toBe(true)
160	})
161	test('the latest user topic wins; assistant badge copy cannot change it', () => {
162		expect(
163			isTicketSalesConversation([
164				{ role: 'user', text: 'I need tickets' },
165				{ role: 'user', text: 'Actually I want to exhibit' },
166				{ role: 'assistant', text: 'Packages include tickets' },
167			]),
168		).toBe(false)
169		expect(
170			isTicketSalesConversation([
171				{ role: 'user', text: 'What sponsorship is available?' },
172				{ role: 'user', text: 'I just want to buy a pass' },
173			]),
174		).toBe(true)
175	})
176})
177
178describe('acceptedSalesOffer', () => {
179	test('a bare yes right after the offer', () => {
180		expect(
181			acceptedSalesOffer(
182				[{ role: 'user', text: 'can i sponsor?' }, offer],
183				'yes please',
184			),
185		).toBe(true)
186		expect(acceptedSalesOffer([offer], 'Sure!')).toBe(true)
187		expect(acceptedSalesOffer([offer], 'that would be great.')).toBe(true)
188	})
189
190	test('not when the offer was not the last assistant turn, or the answer carries content', () => {
191		expect(
192			acceptedSalesOffer(
193				[
194					offer,
195					{ role: 'user', text: 'yes' },
196					{ role: 'assistant', text: 'What is your goal?' },
197				],
198				'yes',
199			),
200		).toBe(false)
201		expect(
202			acceptedSalesOffer([offer], 'yes, we are Acme and want lead generation'),
203		).toBe(false)
204		expect(acceptedSalesOffer([offer], 'no thanks')).toBe(false)
205		expect(
206			acceptedSalesOffer(
207				[
208					{
209						role: 'assistant',
210						text: 'Would you like me to send this agenda to your email?',
211					},
212				],
213				'yes',
214			),
215		).toBe(false)
216	})
217})
218
219test('natural ticket requests from the failed conversations start qualification', () => {
220	for (const text of [
221		'can you get me a diamond ticket?',
222		'can you get me a diamond badge',
223		'Book me two tickets',
224		'I need two tickets',
225		'I want to buy tickets, not sponsor',
226	]) {
227		expect(requestsTicketBooking(text)).toBe(true)
228	}
229	const history = [
230		{
231			role: 'assistant' as const,
232			text: 'General Admission is our core ticket. Would you like help?',
233		},
234	]
235	expect(requestsTicketBooking('can you get me one', history)).toBe(true)
236	expect(requestsTicketBooking('can you get me one')).toBe(false)
237	expect(
238		requestsTicketBooking('can you get me one', [
239			{ role: 'assistant', text: 'Here is an article.' },
240		]),
241	).toBe(false)
242	for (const text of [
243		'What ticket should I buy?',
244		'How do I buy tickets?',
245		'Can I get a Diamond badge?',
246		"I don't want a ticket",
247		'I want to sponsor, with two tickets included',
248	]) {
249		expect(requestsTicketBooking(text)).toBe(false)
250	}
251})
252
253test('accepts a conversational booking enquiry offer without accepting a comparison offer', () => {
254	expect(
255		acceptedSalesOffer(
256			[
257				{
258					role: 'assistant',
259					text:
260						'If you want to go ahead, I can help you start the booking enquiry for the Explorer Badge.',
261				},
262			],
263			'yes',
264		),
265	).toBe(true)
266	expect(
267		acceptedSalesOffer(
268			[{ role: 'assistant', text: 'I can help you compare badges.' }],
269			'yes',
270		),
271	).toBe(false)
272})
273
274test('factual detours and choosing help do not depend on punctuation', () => {
275	for (const text of [
276		'how much does it cost',
277		'how much does it cost?',
278		'Is Diamond refundable?',
279		'help me choose',
280		'compare the passes',
281	])
282		expect(isTicketInformationRequest(text)).toBe(true)
283	for (const text of [
284		'CTO at Acme',
285		'two tickets',
286		'Diamond',
287		'can you get me a badge',
288	])
289		expect(isTicketInformationRequest(text)).toBe(false)
290})
291
292test('contextual buying stays active through missing-field answers', () => {
293	const history = [
294		{ role: 'assistant' as const, text: 'General Admission is our core ticket.' },
295		{ role: 'user' as const, text: 'can you get me one' },
296		{
297			role: 'assistant' as const,
298			text: 'What company are you with, and what is your role?',
299		},
300		{ role: 'user' as const, text: 'CTO at Acme' },
301	]
302	expect(isTicketSalesConversation(history)).toBe(true)
303	expect(
304		isTicketSalesConversation([
305			...history,
306			{ role: 'user', text: 'Actually I want to exhibit' },
307		]),
308	).toBe(false)
309	expect(
310		isTicketSalesConversation([
311			{ role: 'user', text: 'I want to buy tickets, not sponsor' },
312		]),
313	).toBe(true)
314	expect(
315		isTicketSalesConversation([
316			{ role: 'assistant', text: 'I can help you start a booking enquiry.' },
317			{ role: 'user', text: 'yes' },
318		]),
319	).toBe(true)
320})
321
322test('short replies accept pass-selection help instead of repeating qualification', () => {
323	const history = [
324		{
325			role: 'assistant' as const,
326			text: 'Which pass would you like, or would you like help choosing one?',
327		},
328	]
329	for (const text of [
330		'help',
331		'help please',
332		'yes',
333		'yes please',
334		'not sure',
335		"I don't know",
336		'you choose',
337	]) {
338		expect(isTicketInformationRequest(text, history)).toBe(true)
339	}
340	expect(isTicketInformationRequest('Diamond', history)).toBe(false)
341	expect(isTicketInformationRequest('two', history)).toBe(false)
342	expect(
343		isTicketInformationRequest('yes', [
344			{ role: 'assistant', text: 'Shall I send this request with your consent?' },
345		]),
346	).toBe(false)
347})
348
349test('exact Langfuse can I get one follow-up enters booking, not information', () => {
350	const history = [
351		{
352			role: 'assistant' as const,
353			text:
354				'General Admission Badge is our core pass. I can help you compare passes.',
355		},
356	]
357	expect(requestsTicketBooking('can I get one', history)).toBe(true)
358	expect(isTicketInformationRequest('can I get one', history)).toBe(false)
359	expect(
360		requestsTicketBooking('can I get one', [
361			{ role: 'assistant', text: 'Here is a hotel.' },
362		]),
363	).toBe(false)
364})
365
366test('repeated choosing help stays informational after a recommendation', () => {
367	expect(
368		isTicketInformationRequest('help please', [
369			{
370				role: 'assistant',
371				text:
372					'I recommend General Admission. Which would you like to choose: General Admission, Explorer, or Diamond?',
373			},
374		]),
375	).toBe(true)
376})
377
378test('help remains a request after any pass comparison wording', () => {
379	expect(
380		isTicketInformationRequest('help please', [
381			{
382				role: 'assistant',
383				text:
384					'Would you like General Admission, or do you want me to help you compare it with Explorer and Diamond?',
385			},
386		]),
387	).toBe(true)
388})
389
390test('buying logistics and upgrading an existing registration do not start a new sale', () => {
391	for (const text of [
392		'Can I buy a ticket at the venue?',
393		'Can I upgrade my already registered ticket?',
394		'Where can I buy tickets?',
395	]) {
396		expect(requestsTicketBooking(text)).toBe(false)
397		expect(isTicketInformationRequest(text)).toBe(true)
398	}
399	expect(requestsTicketBooking('I want to buy two tickets')).toBe(true)
400})
401
402test('commercial logistics questions stay information without suppressing buying requests', () => {
403	for (const text of [
404		'Can I buy a ticket at the venue?',
405		'Can I upgrade my pass after registering?',
406		'Where can I buy tickets?',
407		'Where do I upload stand plans?',
408		'Can I get a free ticket?',
409		"I'm looking for free tickets",
410	])
411		expect(isCommercialLogisticsQuestion(text)).toBe(true)
412	for (const text of [
413		'I want to buy two tickets',
414		'Please connect me with your sponsorship team',
415		'I want to sponsor the event',
416	])
417		expect(isCommercialLogisticsQuestion(text)).toBe(false)
418})
419
420test('natural agenda email acceptance survives subsequent contact collection', () => {
421	const history = [
422		{
423			role: 'assistant' as const,
424			text: 'Would you like me to email your selected agenda?',
425		},
426	]
427	for (const text of [
428		'Yes, please email it to me.',
429		'Please send my selected agenda to my email.',
430	])
431		expect(acceptedAgendaEmail(history, text)).toBe(true)
432	for (const text of [
433		'No, do not email it',
434		'Please send it tomorrow',
435		'Please send my personal information',
436	])
437		expect(acceptedAgendaEmail(history, text)).toBe(false)
438})
439
440test('registration help and an explicit pass choice switch out of agenda', () => {
441	expect(
442		requestsTicketBooking(
443			"Could you help me with the registration? I'd like to make sure I'm all set with a ticket first.",
444		),
445	).toBe(true)
446	expect(
447		requestsTicketBooking(
448			"I'd like to go ahead with the General Admission Badge.",
449		),
450	).toBe(true)
451	expect(requestsTicketBooking('Where is the registration desk?')).toBe(false)
452})
453
454test('recipient-bearing agenda acceptance stays pending through name collection', () => {
455	const history = [
456		{
457			role: 'assistant' as const,
458			text:
459				'Your UNLEASH in Paris agenda\nWould you like me to email your selected agenda?',
460		},
461	]
462	expect(
463		acceptedAgendaEmail(
464			history,
465			'Perfect, yes please send that to jordan.blake@example.com',
466		),
467	).toBe(true)
468	expect(
469		acceptedAgendaEmail(history, 'Do not send that to jordan.blake@example.com'),
470	).toBe(false)
471	expect(isCommercialLogisticsQuestion('How do I book my place?')).toBe(true)
472	expect(isCommercialLogisticsQuestion('and the group tickets page?')).toBe(true)
473})
474
475test('team quote handoffs can proceed without selecting a pass; closing on an external registration does not re-open qualification', () => {
476	const quote = 'Can your team follow up with a custom quote for a group of 4?'
477	expect(requestsTicketBooking(quote)).toBe(true)
478	expect(isTicketInformationRequest(quote)).toBe(false)
479	expect(requestsTicketBooking('What is group pricing?')).toBe(false)
480	expect(
481		requestsTicketBooking('Please do not contact me about a custom quote'),
482	).toBe(false)
483	expect(
484		isConversationClosing(
485			"Thanks, I'll go ahead and register through the startup pass link.",
486		),
487	).toBe(true)
488	expect(isConversationClosing('Thanks, can you register me?')).toBe(false)
489})
490
491test('thanking us before contacting the team is a closing, not a new startup facts request', () => {
492	expect(
493		isConversationClosing(
494			"That's helpful, thanks. I'll reach out to customersuccess@unleash.ai to learn more about the Startup Award details before we register.",
495		),
496	).toBe(true)
497	expect(
498		isConversationClosing('Thanks, can you tell me more about the Award?'),
499	).toBe(false)
500})
501
502test('external Support sign-off does not repeat a pending enquiry summary', () => {
503	expect(isConversationClosing('Thanks, I will contact support directly.')).toBe(
504		true,
505	)
506	expect(isConversationClosing('Thanks, will you contact support for me?')).toBe(
507		false,
508	)
509})
510
511test('a polite pricing-information request does not collect company/role', () => {
512	const message =
513		"I'd definitely like to hear about the pass options and pricing. How many team members could we bring, and what would that look like cost-wise for our organization?"
514	expect(isTicketInformationRequest(message)).toBe(true)
515	expect(isCommercialInformationRequest(message)).toBe(true)
516	expect(requestsTicketBooking(message)).toBe(false)
517	expect(
518		isCommercialInformationRequest(
519			'Can your team follow up with a custom quote for a group of 4?',
520		),
521	).toBe(false)
522})
523