1- import { expect , test } from '@playwright/test' ;
1+ import { expect , test , type Page , type Response } from '@playwright/test' ;
22import { readFileSync } from 'node:fs' ;
33import { join } from 'node:path' ;
44import { validateRuntimeParentOrigins } from '@threadplane/cockpit-runtime-bridge' ;
@@ -13,7 +13,10 @@ import {
1313 *
1414 * Requires:
1515 * EXAMPLES_URL - e.g. https://examples.threadplane.ai
16- * OPENAI_API_KEY - optional; enables the single live-provider canary
16+ * OPENAI_API_KEY - optional; enables the canonical LangGraph telemetry canary
17+ *
18+ * AG-UI canaries always exercise the deployed services' own provider keys.
19+ * They must not skip when the test runner has no local provider key.
1720 *
1821 * Run:
1922 * PRODUCTION_SMOKE=true \
@@ -25,6 +28,8 @@ const EXAMPLES_URL =
2528 process . env [ 'EXAMPLES_URL' ] ?? 'https://examples.threadplane.ai' ;
2629const DEMO_URL = process . env [ 'DEMO_URL' ] ?? 'https://demo.threadplane.ai' ;
2730const WEBSITE_URL = process . env [ 'WEBSITE_URL' ] ?? 'https://threadplane.ai' ;
31+ const AG_UI_DEMO_URL =
32+ process . env [ 'AG_UI_DEMO_URL' ] ?? 'https://ag-ui.threadplane.ai' ;
2833// Playwright transpiles specs to CJS, so `import.meta.url` here compiles to a
2934// `require` the ESM-loaded output cannot resolve and the whole file fails to
3035// load. `__dirname` is what the emitted module actually has. Don't "modernise"
@@ -393,8 +398,141 @@ test.describe('examples langgraph proxy hardening', () => {
393398 } ) ;
394399} ) ;
395400
401+ // A provider authentication error can close an HTTP-200 stream before an
402+ // interrupt or reply arrives. Require protocol completion, not just transport
403+ // success, so this fails promptly with a useful diagnostic in that case.
404+ async function completedAgentEvents ( response : Response ) {
405+ expect ( response . status ( ) , response . url ( ) ) . toBe ( 200 ) ;
406+ const events = ( await response . text ( ) )
407+ . split ( / \r ? \n / )
408+ . filter ( ( line ) => line . startsWith ( 'data:' ) )
409+ . map ( ( line ) => JSON . parse ( line . slice ( 5 ) . trim ( ) ) as Record < string , unknown > ) ;
410+ const types = events . map ( ( event ) => event [ 'type' ] ) ;
411+ expect ( types , 'Agent returned RUN_ERROR' ) . not . toContain ( 'RUN_ERROR' ) ;
412+ expect (
413+ types ,
414+ 'Agent stream ended without RUN_FINISHED; check deployed provider credentials and runtime logs'
415+ ) . toContain ( 'RUN_FINISHED' ) ;
416+ return events ;
417+ }
418+
419+ function nextAgentResponse ( page : Page ) {
420+ return page . waitForResponse (
421+ ( response ) =>
422+ response . request ( ) . method ( ) === 'POST' &&
423+ new URL ( response . url ( ) ) . pathname . endsWith ( '/agent' ) ,
424+ { timeout : 60_000 }
425+ ) ;
426+ }
427+
428+ test . describe ( 'Production: live AG-UI provider canaries' , ( ) => {
429+ // These are synthetic demo operations. Run sequentially and bound retries
430+ // separately from dev-server startup retries to limit live provider usage.
431+ test . describe . configure ( { mode : 'default' , timeout : 120_000 , retries : 1 } ) ;
432+
433+ for ( const runtime of [ 'refund' , 'mastra' ] as const ) {
434+ for ( const approved of [ true , false ] ) {
435+ const action = approved ? 'Approve' : 'Cancel' ;
436+ test ( `${ runtime } : ${ action } completes the live approval flow` , async ( {
437+ page,
438+ } ) => {
439+ const mastra = runtime === 'mastra' ;
440+ await page . goto (
441+ `${ EXAMPLES_URL } /${ mastra ? 'runtimes/mastra' : 'ag-ui/interrupts' } /`
442+ ) ;
443+ const initialResponse = nextAgentResponse ( page ) ;
444+ await page
445+ . getByText (
446+ mastra ? 'Reserve the campsite' : 'Refund a duplicate charge' ,
447+ { exact : true }
448+ )
449+ . click ( ) ;
450+ const initialEvents = await completedAgentEvents ( await initialResponse ) ;
451+ expect ( initialEvents ) . toEqual (
452+ expect . arrayContaining ( [
453+ expect . objectContaining ( { type : 'CUSTOM' , name : 'on_interrupt' } ) ,
454+ ] )
455+ ) ;
456+ const dialog = page . locator ( 'dialog.chat-approval-card' ) ;
457+ await expect ( dialog ) . toBeVisible ( ) ;
458+ await expect ( dialog ) . toContainText (
459+ mastra ? 'Reservation approval required' : 'Refund approval required'
460+ ) ;
461+ await expect ( dialog ) . toContainText (
462+ mastra ? 'North Pines' : 'cus_a8x2k'
463+ ) ;
464+ await expect ( dialog ) . toContainText ( mastra ? '$90.00' : '$47.50' ) ;
465+
466+ const resumedResponse = nextAgentResponse ( page ) ;
467+ await dialog . getByRole ( 'button' , { name : action , exact : true } ) . click ( ) ;
468+ const response = await resumedResponse ;
469+ const command = response . request ( ) . postDataJSON ( )
470+ . forwardedProps . command ;
471+ expect ( command . resume . approved ) . toBe ( approved ) ;
472+ const events = await completedAgentEvents ( response ) ;
473+ await expect ( dialog ) . not . toBeVisible ( ) ;
474+ const reply = page
475+ . locator ( 'chat-message[data-role="assistant"]' )
476+ . last ( ) ;
477+
478+ if ( mastra ) {
479+ expect ( command . interruptEvent . toolCallId ) . toEqual ( expect . any ( String ) ) ;
480+ expect ( command . interruptEvent . runId ) . toEqual ( expect . any ( String ) ) ;
481+ expect ( events ) . toEqual (
482+ expect . arrayContaining ( [
483+ expect . objectContaining ( {
484+ type : 'TOOL_CALL_RESULT' ,
485+ toolCallId : command . interruptEvent . toolCallId ,
486+ content : expect . stringContaining (
487+ approved ? 'Reserved North Pines' : 'Nothing was booked.'
488+ ) ,
489+ } ) ,
490+ ] )
491+ ) ;
492+ const cancellation =
493+ / d e c l i n e d | c a n c e l l e d | c a n c e l e d | n o t .{ 0 , 20 } ( b o o k e d | c o m p l e t e d | c o n f i r m e d | r e s e r v e d ) | n o t h i n g .{ 0 , 20 } b o o k e d / i;
494+ await expect ( reply ) . toContainText (
495+ approved ? / r e s e r v e d | c o n f i r m e d | b o o k e d / i : cancellation
496+ ) ;
497+ if ( approved ) await expect ( reply ) . not . toContainText ( cancellation ) ;
498+ else await expect ( reply ) . not . toContainText ( 'TP-0288' ) ;
499+ } else {
500+ await expect ( reply ) . toContainText (
501+ approved
502+ ? 'Refund of $47.50 issued to'
503+ : 'Refund cancelled by operator. No charge issued.'
504+ ) ;
505+ if ( approved ) await expect ( reply ) . toContainText ( 'cus_a8x2k' ) ;
506+ else await expect ( reply ) . not . toContainText ( 'Refund ID:' ) ;
507+ }
508+ } ) ;
509+ }
510+ }
511+
512+ test ( 'AG-UI demo completes a live reply' , async ( { page } ) => {
513+ await page . goto ( `${ AG_UI_DEMO_URL } /embed` ) ;
514+ await page
515+ . locator ( 'textarea[name="messageText"]' )
516+ . fill ( 'Say hello in one sentence.' ) ;
517+ const response = nextAgentResponse ( page ) ;
518+ await page . getByRole ( 'button' , { name : / s e n d m e s s a g e / i } ) . click ( ) ;
519+ const events = await completedAgentEvents ( await response ) ;
520+ expect ( events ) . toEqual (
521+ expect . arrayContaining ( [
522+ expect . objectContaining ( {
523+ type : 'TEXT_MESSAGE_CONTENT' ,
524+ delta : expect . stringMatching ( / \S / ) ,
525+ } ) ,
526+ ] )
527+ ) ;
528+ await expect (
529+ page . locator ( 'chat-message[data-role="assistant"]' ) . last ( )
530+ ) . toContainText ( / \S / ) ;
531+ } ) ;
532+ } ) ;
533+
396534test . describe ( 'AG-UI demo (ag-ui.threadplane.ai)' , ( ) => {
397- const DEMO = process . env [ ' AG_UI_DEMO_URL' ] ?? 'https://ag-ui.threadplane.ai' ;
535+ const DEMO = AG_UI_DEMO_URL ;
398536
399537 test ( 'demo SPA is reachable' , async ( { page } ) => {
400538 const res = await page . goto ( `${ DEMO } /` ) ;
0 commit comments