Skip to content

Commit f26fd0c

Browse files
authored
feat(persona): persona-aware intro DM + system prompt overlay (Slice 16b) (#121)
PERSONA_CATALOG vendors the seven character_ids (Lilian, Marcus, Sloane, Theo, Priya, Ryan, Adrian) with display_name, subtitle, tagline, default_tools, default_model, the three intro_* fields, system_prompt_overlay, day1_work_description, and day1_data_pulls. The seven character_ids are the cross-repo locked fixture; slices 16c (phantomd), 16d (phantom-control), and 16e (ghostwright-site) mirror them byte-for-byte and a verifier runs in CI of any of the four repos. The intro DM (PR #120's slack-introduction.ts) reads PHANTOM_PERSONA_ID and overlays the persona's display_name + intro_role_phrase + intro_first_commitment + intro_open_offer onto the existing scaffold. The Block Kit wire shape (header + body section + actions row + offer section + optional context footer), the actions block_id "phantom_morning_brief", the three button action_ids, and the primary style on Lock 8am all stay byte-equal across every persona AND the persona-less fallback. Only the text content shifts. When PHANTOM_PERSONA_ID is unset or unknown, the PR #120 default copy ships unchanged. The persona system-prompt overlay slots in at slot 1c of prompt-assembler, after the tenant-self-knowledge overlay (slot 1b). It renders a "# Your Voice And Role" section with the persona's system_prompt_overlay text and substitutes \${ownerName} from PHANTOM_OWNER_NAME (fallback "your founder"). When PHANTOM_PERSONA_ID is unset or unknown, the slot drops cleanly with the empty string, matching the tenant-self-knowledge degradation pattern. The persona work-plan is a stub today: getPersonaWorkPlan returns the shape with empty data_pulls, drafts, and open_questions for each known persona, null for unknown / unset. Slice 15 (First Hour of Work) fills the actual content; this PR locks the type contract so slice 15 plugs in without a shape change. Tests: catalog (28 cases pin completeness, voice contract no em dashes no emojis no marketing voice, the seven verbatim taglines and role phrases against the architect doc, getPersonaById, readPersonaFromEnv); work-plan (5 cases pin the stub shape and mutability for slice 15); system-prompt (14 cases pin the per-persona overlay rendering, the \${ownerName} substitution, the empty-string fallback, the env reader); slack-introduction extended (89 vs 33 baseline; the seven personas each render the persona- specific copy in text + Block Kit, the wire shape stays byte-equal across personas, the persona-less fallback ships PR #120 copy, the unset/unknown/empty PHANTOM_PERSONA_ID degrades cleanly). Test count delta: +103 (2428 baseline -> 2531 after slice 16b). Typecheck clean. Lint clean.
1 parent d4489e2 commit f26fd0c

9 files changed

Lines changed: 1146 additions & 17 deletions

File tree

src/agent/prompt-assembler.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
22
import { join } from "node:path";
33
import type { PhantomConfig } from "../config/types.ts";
44
import type { EvolvedConfig } from "../evolution/types.ts";
5+
import { buildPersonaSystemPromptOverlay } from "../persona/system-prompt.ts";
56
import type { RoleTemplate } from "../roles/types.ts";
67
import { buildAgentMemoryInstructions } from "./prompt-blocks/agent-memory-instructions.ts";
78
import { buildDashboardAwarenessLines } from "./prompt-blocks/dashboard-awareness.ts";
@@ -37,6 +38,17 @@ export function assemblePrompt(
3738
sections.push(selfKnowledge);
3839
}
3940

41+
// 1c. Persona overlay (Slice 16b). Reads PHANTOM_PERSONA_ID and
42+
// emits the persona's system_prompt_overlay as a "# Your Voice And
43+
// Role" section. Returns the empty string when the persona id is
44+
// unset or unknown so the slot drops cleanly. Sits after the
45+
// tenant-self-knowledge block so the agent reads its own identity
46+
// (workspace, owner, runtime) before its voice and role.
47+
const personaOverlay = buildPersonaSystemPromptOverlay();
48+
if (personaOverlay) {
49+
sections.push(personaOverlay);
50+
}
51+
4052
// 2. Environment - what you have access to
4153
sections.push(buildEnvironment(config));
4254

src/channels/__tests__/slack-introduction.test.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2+
import { PERSONA_CATALOG, PERSONA_IDS, type PersonaId } from "../../persona/catalog.ts";
23
import type { SlackBlock } from "../feedback.ts";
34
import {
45
MORNING_BRIEF_LOCK_ACTION_ID,
@@ -35,6 +36,7 @@ const ORIGINAL_DASHBOARD = process.env.PHANTOM_DASHBOARD_URL;
3536
const ORIGINAL_OWNER_NAME = process.env.PHANTOM_OWNER_NAME;
3637
const ORIGINAL_DOMAIN = process.env.PHANTOM_DOMAIN;
3738
const ORIGINAL_TENANT_SLUG = process.env.PHANTOM_TENANT_SLUG;
39+
const ORIGINAL_PERSONA_ID = process.env.PHANTOM_PERSONA_ID;
3840

3941
beforeEach(() => {
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

5053
afterEach(() => {
@@ -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

8391
describe("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

Comments
 (0)