11import { afterEach , beforeEach , describe , expect , mock , test } from "bun:test" ;
2+ import { PERSONA_CATALOG , PERSONA_IDS , type PersonaId } from "../../persona/catalog.ts" ;
23import type { SlackBlock } from "../feedback.ts" ;
34import {
45 MORNING_BRIEF_LOCK_ACTION_ID ,
@@ -35,6 +36,7 @@ const ORIGINAL_DASHBOARD = process.env.PHANTOM_DASHBOARD_URL;
3536const ORIGINAL_OWNER_NAME = process . env . PHANTOM_OWNER_NAME ;
3637const ORIGINAL_DOMAIN = process . env . PHANTOM_DOMAIN ;
3738const ORIGINAL_TENANT_SLUG = process . env . PHANTOM_TENANT_SLUG ;
39+ const ORIGINAL_PERSONA_ID = process . env . PHANTOM_PERSONA_ID ;
3840
3941beforeEach ( ( ) => {
4042 heartbeatCalls . length = 0 ;
@@ -45,6 +47,7 @@ beforeEach(() => {
4547 process . env . PHANTOM_OWNER_NAME = undefined ;
4648 process . env . PHANTOM_DOMAIN = undefined ;
4749 process . env . PHANTOM_TENANT_SLUG = undefined ;
50+ process . env . PHANTOM_PERSONA_ID = undefined ;
4851} ) ;
4952
5053afterEach ( ( ) => {
@@ -78,6 +81,11 @@ afterEach(() => {
7881 } else {
7982 process . env . PHANTOM_TENANT_SLUG = ORIGINAL_TENANT_SLUG ;
8083 }
84+ if ( ORIGINAL_PERSONA_ID === undefined ) {
85+ process . env . PHANTOM_PERSONA_ID = undefined ;
86+ } else {
87+ process . env . PHANTOM_PERSONA_ID = ORIGINAL_PERSONA_ID ;
88+ }
8189} ) ;
8290
8391describe ( "composeIntroductionText" , ( ) => {
@@ -516,3 +524,264 @@ describe("sendIntroductionDm", () => {
516524 expect ( all ) . not . toContain ( "xoxb-leaky-token-XXX" ) ;
517525 } ) ;
518526} ) ;
527+
528+ // Slice 16b: persona overlay coverage.
529+ //
530+ // The wire shape from PR #120 (Block Kit header + body section + actions
531+ // row + offer section + optional context footer) STAYS THE SAME under
532+ // the persona overlay. The actions row block_id, the three button
533+ // action_ids, the primary style on Lock 8am, and the order of the
534+ // buttons stay byte-equal across every persona AND the persona-less
535+ // fallback. The text content shifts to the persona's display_name +
536+ // intro_role_phrase + intro_first_commitment + intro_open_offer.
537+
538+ describe ( "composeIntroductionText with persona overlay" , ( ) => {
539+ for ( const id of PERSONA_IDS ) {
540+ const persona = PERSONA_CATALOG [ id ] ;
541+ test ( `${ id } text fallback uses the persona's named greeting + role phrase + first_commitment + open_offer` , ( ) => {
542+ const text = composeIntroductionText (
543+ "Phantom" ,
544+ "Cheema" ,
545+ "https://gilded-hearth.phantom.ghostwright.dev" ,
546+ undefined ,
547+ persona ,
548+ ) ;
549+ expect ( text ) . toContain ( `I'm ${ persona . display_name } , ${ persona . intro_role_phrase } .` ) ;
550+ expect ( text ) . toContain ( persona . intro_first_commitment ) ;
551+ expect ( text ) . toContain ( persona . intro_open_offer ) ;
552+ // The wire-shape cue ("buttons below") stays so screen-reader
553+ // users do not dead-end at the question regardless of persona.
554+ expect ( text ) . toContain ( "buttons below" ) ;
555+ } ) ;
556+ }
557+
558+ test ( "falls back to the PR #120 default copy when no persona is provided" , ( ) => {
559+ const text = composeIntroductionText (
560+ "Phantom" ,
561+ "Cheema" ,
562+ "https://gilded-hearth.phantom.ghostwright.dev" ,
563+ undefined ,
564+ undefined ,
565+ ) ;
566+ expect ( text ) . toContain ( "I'm in." ) ;
567+ expect ( text ) . toContain ( "co-worker in this Slack as Phantom" ) ;
568+ expect ( text ) . toContain (
569+ "Tomorrow at 8am your time I'll send you a quick read on what changed overnight and what needs you" ,
570+ ) ;
571+ expect ( text ) . toContain ( "If you want me to start on something now, just tell me what." ) ;
572+ } ) ;
573+
574+ test ( "never contains an em dash, en dash, or marketing voice across all seven personas" , ( ) => {
575+ for ( const id of PERSONA_IDS ) {
576+ const persona = PERSONA_CATALOG [ id ] ;
577+ const text = composeIntroductionText (
578+ "Phantom" ,
579+ "Cheema" ,
580+ "https://gilded-hearth.phantom.ghostwright.dev" ,
581+ "https://app.ghostwright.dev" ,
582+ persona ,
583+ ) ;
584+ expect ( text ) . not . toContain ( "—" ) ;
585+ expect ( text ) . not . toContain ( "–" ) ;
586+ expect ( text ) . not . toContain ( "blazing" ) ;
587+ expect ( text ) . not . toContain ( "Welcome to the future" ) ;
588+ expect ( text ) . not . toContain ( "✨" ) ;
589+ }
590+ } ) ;
591+ } ) ;
592+
593+ describe ( "composeIntroductionBlocks with persona overlay" , ( ) => {
594+ for ( const id of PERSONA_IDS ) {
595+ const persona = PERSONA_CATALOG [ id ] ;
596+ test ( `${ id } header text uses the persona's display_name` , ( ) => {
597+ const blocks = composeIntroductionBlocks ( "Phantom" , "Cheema" , "https://example.test" , undefined , persona ) ;
598+ const header = blocks . find ( ( b ) => b . type === "header" ) ;
599+ expect ( header ?. text ?. text ) . toBe ( `${ persona . display_name } is in.` ) ;
600+ } ) ;
601+
602+ test ( `${ id } body section identifies the agent by display_name + role_phrase` , ( ) => {
603+ const blocks = composeIntroductionBlocks ( "Phantom" , "Cheema" , "https://example.test" , undefined , persona ) ;
604+ const bodySection = blocks . find (
605+ ( b ) => b . type === "section" && ( b . text ?. text ?? "" ) . includes ( "I'll be in this Slack" ) ,
606+ ) ;
607+ expect ( bodySection ) . toBeDefined ( ) ;
608+ expect ( bodySection ?. text ?. text ) . toContain ( persona . display_name ) ;
609+ expect ( bodySection ?. text ?. text ) . toContain ( persona . intro_role_phrase ) ;
610+ expect ( bodySection ?. text ?. text ) . toContain ( persona . intro_first_commitment ) ;
611+ } ) ;
612+
613+ test ( `${ id } offer section uses the persona's intro_open_offer` , ( ) => {
614+ const blocks = composeIntroductionBlocks ( "Phantom" , "Cheema" , "https://example.test" , undefined , persona ) ;
615+ const offerSection = blocks . find (
616+ ( b ) => b . type === "section" && ( b . text ?. text ?? "" ) . includes ( persona . intro_open_offer ) ,
617+ ) ;
618+ expect ( offerSection ) . toBeDefined ( ) ;
619+ } ) ;
620+
621+ test ( `${ id } preserves the exact PR #120 actions wire shape (block_id, action_ids, primary on Lock 8am)` , ( ) => {
622+ const blocks = composeIntroductionBlocks ( "Phantom" , "Cheema" , "https://example.test" , undefined , persona ) ;
623+ const actions = blocks . find ( ( b ) => b . type === "actions" ) as SlackBlock & {
624+ elements ?: Array < { action_id ?: string ; text ?: { text ?: string } ; style ?: string ; value ?: string } > ;
625+ } ;
626+ expect ( actions ?. block_id ) . toBe ( "phantom_morning_brief" ) ;
627+ expect ( actions ?. elements ?. length ) . toBe ( 3 ) ;
628+ expect ( actions ?. elements ?. map ( ( e ) => e . action_id ) ) . toEqual ( [
629+ MORNING_BRIEF_LOCK_ACTION_ID ,
630+ MORNING_BRIEF_RETIME_ACTION_ID ,
631+ MORNING_BRIEF_SKIP_ACTION_ID ,
632+ ] ) ;
633+ expect ( actions ?. elements ?. [ 0 ] ?. style ) . toBe ( "primary" ) ;
634+ expect ( actions ?. elements ?. [ 0 ] ?. text ?. text ) . toBe ( "Lock 8am" ) ;
635+ expect ( actions ?. elements ?. [ 1 ] ?. text ?. text ) . toBe ( "Pick another time" ) ;
636+ expect ( actions ?. elements ?. [ 2 ] ?. text ?. text ) . toBe ( "Skip mornings" ) ;
637+ } ) ;
638+ }
639+
640+ test ( "default header (no persona) keeps the PR #120 phantomName-driven copy" , ( ) => {
641+ const blocks = composeIntroductionBlocks ( "Phantom" , "Cheema" , "https://example.test" , undefined , undefined ) ;
642+ const header = blocks . find ( ( b ) => b . type === "header" ) ;
643+ expect ( header ?. text ?. text ) . toBe ( "Phantom is in." ) ;
644+ } ) ;
645+
646+ test ( "dashboard context footer renders for any persona when PHANTOM_DASHBOARD_URL is set" , ( ) => {
647+ for ( const id of PERSONA_IDS ) {
648+ const persona = PERSONA_CATALOG [ id ] ;
649+ const blocks = composeIntroductionBlocks (
650+ "Phantom" ,
651+ "Cheema" ,
652+ "https://example.test" ,
653+ "https://app.ghostwright.dev" ,
654+ persona ,
655+ ) ;
656+ expect ( blocks . some ( ( b ) => b . type === "context" ) ) . toBe ( true ) ;
657+ }
658+ } ) ;
659+ } ) ;
660+
661+ describe ( "sendIntroductionDm reads PHANTOM_PERSONA_ID and threads the persona through" , ( ) => {
662+ for ( const id of PERSONA_IDS as readonly PersonaId [ ] ) {
663+ const persona = PERSONA_CATALOG [ id ] ;
664+ test ( `${ id } : PHANTOM_PERSONA_ID drives the persona's display_name in the header and the body sentence` , async ( ) => {
665+ process . env . PHANTOM_OWNER_NAME = "Cheema" ;
666+ process . env . PHANTOM_DOMAIN = "gilded-hearth.phantom.ghostwright.dev" ;
667+ process . env . PHANTOM_PERSONA_ID = id ;
668+ let capturedText = "" ;
669+ let capturedBlocks : SlackBlock [ ] | undefined ;
670+ const sendDm = mock ( async ( _userId : string , text : string , blocks ?: SlackBlock [ ] ) => {
671+ capturedText = text ;
672+ capturedBlocks = blocks ;
673+ return "1715000000.999000" as string | null ;
674+ } ) ;
675+ const result = await sendIntroductionDm ( {
676+ phantomName : "Phantom" ,
677+ teamName : "Acme" ,
678+ installerUserId : "U_INSTALLER" ,
679+ sendDm,
680+ } ) ;
681+
682+ expect ( result . sent ) . toBe ( true ) ;
683+ expect ( capturedText ) . toContain ( `I'm ${ persona . display_name } , ${ persona . intro_role_phrase } .` ) ;
684+ expect ( capturedText ) . toContain ( persona . intro_first_commitment ) ;
685+ expect ( capturedText ) . toContain ( persona . intro_open_offer ) ;
686+ const header = capturedBlocks ?. find ( ( b ) => b . type === "header" ) ;
687+ expect ( header ?. text ?. text ) . toBe ( `${ persona . display_name } is in.` ) ;
688+ const actions = capturedBlocks ?. find ( ( b ) => b . type === "actions" ) ;
689+ expect ( actions ?. block_id ) . toBe ( "phantom_morning_brief" ) ;
690+ } ) ;
691+ }
692+
693+ test ( "falls back to default scaffold when PHANTOM_PERSONA_ID is unset" , async ( ) => {
694+ process . env . PHANTOM_OWNER_NAME = "Cheema" ;
695+ process . env . PHANTOM_DOMAIN = "gilded-hearth.phantom.ghostwright.dev" ;
696+ let capturedText = "" ;
697+ let capturedBlocks : SlackBlock [ ] | undefined ;
698+ const sendDm = mock ( async ( _userId : string , text : string , blocks ?: SlackBlock [ ] ) => {
699+ capturedText = text ;
700+ capturedBlocks = blocks ;
701+ return "1715000000.998000" as string | null ;
702+ } ) ;
703+ await sendIntroductionDm ( {
704+ phantomName : "Phantom" ,
705+ teamName : "Acme" ,
706+ installerUserId : "U_INSTALLER" ,
707+ sendDm,
708+ } ) ;
709+
710+ expect ( capturedText ) . toContain ( "co-worker in this Slack as Phantom" ) ;
711+ expect ( capturedText ) . toContain (
712+ "Tomorrow at 8am your time I'll send you a quick read on what changed overnight and what needs you" ,
713+ ) ;
714+ const header = capturedBlocks ?. find ( ( b ) => b . type === "header" ) ;
715+ expect ( header ?. text ?. text ) . toBe ( "Phantom is in." ) ;
716+ } ) ;
717+
718+ test ( "falls back to default scaffold when PHANTOM_PERSONA_ID is unknown (defends against typos and validator drift)" , async ( ) => {
719+ process . env . PHANTOM_OWNER_NAME = "Cheema" ;
720+ process . env . PHANTOM_PERSONA_ID = "sdr-typo" ;
721+ let capturedText = "" ;
722+ const sendDm = mock ( async ( _userId : string , text : string , _blocks ?: SlackBlock [ ] ) => {
723+ capturedText = text ;
724+ return "1715000000.997000" as string | null ;
725+ } ) ;
726+ await sendIntroductionDm ( {
727+ phantomName : "Phantom" ,
728+ teamName : "Acme" ,
729+ installerUserId : "U_INSTALLER" ,
730+ sendDm,
731+ } ) ;
732+
733+ expect ( capturedText ) . toContain ( "co-worker in this Slack as Phantom" ) ;
734+ expect ( capturedText ) . not . toContain ( "sdr-typo" ) ;
735+ } ) ;
736+
737+ test ( "falls back to default scaffold when PHANTOM_PERSONA_ID is empty string" , async ( ) => {
738+ process . env . PHANTOM_PERSONA_ID = "" ;
739+ let capturedText = "" ;
740+ const sendDm = mock ( async ( _userId : string , text : string , _blocks ?: SlackBlock [ ] ) => {
741+ capturedText = text ;
742+ return "1715000000.996000" as string | null ;
743+ } ) ;
744+ await sendIntroductionDm ( {
745+ phantomName : "Phantom" ,
746+ teamName : "Acme" ,
747+ installerUserId : "U_INSTALLER" ,
748+ sendDm,
749+ } ) ;
750+
751+ expect ( capturedText ) . toContain ( "co-worker in this Slack as Phantom" ) ;
752+ } ) ;
753+ } ) ;
754+
755+ describe ( "intro DM Block Kit snapshot per persona" , ( ) => {
756+ // Snapshot test (architect doc §6 Mandate E): each persona's rendered
757+ // Block Kit JSON. We pin the structural shape (header + sections +
758+ // actions + optional context) plus the persona-specific copy that
759+ // shifts in each block. This catches accidental drift between
760+ // persona text content and the block scaffolding without coupling
761+ // to Slack's wire format minutiae.
762+ for ( const id of PERSONA_IDS ) {
763+ const persona = PERSONA_CATALOG [ id ] ;
764+ test ( `${ id } renders the locked Block Kit shape with persona-specific copy` , ( ) => {
765+ const blocks = composeIntroductionBlocks (
766+ "Phantom" ,
767+ "Cheema" ,
768+ "https://gilded-hearth.phantom.ghostwright.dev" ,
769+ "https://app.ghostwright.dev" ,
770+ persona ,
771+ ) ;
772+ // Shape: header + body section + actions + offer section + context.
773+ expect ( blocks . length ) . toBe ( 5 ) ;
774+ expect ( blocks [ 0 ] ?. type ) . toBe ( "header" ) ;
775+ expect ( blocks [ 1 ] ?. type ) . toBe ( "section" ) ;
776+ expect ( blocks [ 2 ] ?. type ) . toBe ( "actions" ) ;
777+ expect ( blocks [ 3 ] ?. type ) . toBe ( "section" ) ;
778+ expect ( blocks [ 4 ] ?. type ) . toBe ( "context" ) ;
779+ // Persona-specific content lands in the right blocks.
780+ expect ( blocks [ 0 ] ?. text ?. text ) . toBe ( `${ persona . display_name } is in.` ) ;
781+ expect ( blocks [ 1 ] ?. text ?. text ) . toContain ( persona . display_name ) ;
782+ expect ( blocks [ 1 ] ?. text ?. text ) . toContain ( persona . intro_role_phrase ) ;
783+ expect ( blocks [ 1 ] ?. text ?. text ) . toContain ( persona . intro_first_commitment ) ;
784+ expect ( blocks [ 3 ] ?. text ?. text ) . toContain ( persona . intro_open_offer ) ;
785+ } ) ;
786+ }
787+ } ) ;
0 commit comments