From 2bc831d0ec28ff559fc9977958edf51c81180113 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Mon, 30 Mar 2026 23:05:10 +0000 Subject: [PATCH 1/6] feat: pass songs param from Slack bot to content creation pipeline The AI prompt parser now extracts song names from natural-language Slack mentions (e.g. "make a lipsync video for the hiccups song") and forwards them to triggerCreateContent so the pipeline filters to the requested songs. Co-Authored-By: Paperclip --- .../__tests__/parseContentPrompt.test.ts | 31 +++++++++++ .../__tests__/registerOnNewMention.test.ts | 54 +++++++++++++++++++ .../content/createContentPromptAgent.ts | 6 +++ .../content/handlers/registerOnNewMention.ts | 3 +- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/lib/agents/content/__tests__/parseContentPrompt.test.ts b/lib/agents/content/__tests__/parseContentPrompt.test.ts index 10aa8014..7c9a741e 100644 --- a/lib/agents/content/__tests__/parseContentPrompt.test.ts +++ b/lib/agents/content/__tests__/parseContentPrompt.test.ts @@ -157,4 +157,35 @@ describe("parseContentPrompt", () => { template: "artist-caption-bedroom", }); }); + + it("extracts songs from prompt when specified", async () => { + const flags: ContentPromptFlags = { + lipsync: true, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + songs: ["hiccups"], + }; + mockGenerate.mockResolvedValue({ output: flags }); + + const result = await parseContentPrompt("make a lipsync video for the hiccups song"); + + expect(result.songs).toEqual(["hiccups"]); + }); + + it("returns undefined songs when no songs mentioned", async () => { + const flags: ContentPromptFlags = { + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }; + mockGenerate.mockResolvedValue({ output: flags }); + + const result = await parseContentPrompt("make me a video"); + + expect(result.songs).toBeUndefined(); + }); }); diff --git a/lib/agents/content/__tests__/registerOnNewMention.test.ts b/lib/agents/content/__tests__/registerOnNewMention.test.ts index 001d6d92..3b0a09dd 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -190,6 +190,60 @@ describe("registerOnNewMention", () => { ); }); + it("passes parsed songs to triggerCreateContent", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: true, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + songs: ["hiccups"], + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make a lipsync video for the hiccups song"); + await bot.getHandler()!(thread, message); + + expect(triggerCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ songs: ["hiccups"] }), + ); + }); + + it("omits songs from triggerCreateContent when not specified", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make me a video"); + await bot.getHandler()!(thread, message); + + const payload = vi.mocked(triggerCreateContent).mock.calls[0][0]; + expect(payload).not.toHaveProperty("songs"); + }); + it("includes lipsync and batch info in acknowledgment message", async () => { const bot = createMockBot(); registerOnNewMention(bot as never); diff --git a/lib/agents/content/createContentPromptAgent.ts b/lib/agents/content/createContentPromptAgent.ts index 8f5c6a14..efe22088 100644 --- a/lib/agents/content/createContentPromptAgent.ts +++ b/lib/agents/content/createContentPromptAgent.ts @@ -30,6 +30,12 @@ export const contentPromptFlagsSchema = z.object({ "Whether to upscale for higher quality. True when the prompt mentions high quality, HD, upscale, 4K, or premium.", ), template: z.enum(templateNames).describe("Which visual template/scene to use for the video."), + songs: z + .array(z.string().min(1)) + .optional() + .describe( + "Song names or slugs mentioned in the prompt. Extract from phrases like 'the hiccups song', 'use track X', 'for song Y'. Omit if no specific songs are mentioned.", + ), }); export type ContentPromptFlags = z.infer; diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 8a51693c..18bc396b 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -21,7 +21,7 @@ export function registerOnNewMention(bot: ContentAgentBot) { const artistAccountId = "1873859c-dd37-4e9a-9bac-80d3558527a9"; // Parse the user's natural-language prompt into structured flags - const { lipsync, batch, captionLength, upscale, template } = await parseContentPrompt( + const { lipsync, batch, captionLength, upscale, template, songs } = await parseContentPrompt( message.text, ); @@ -71,6 +71,7 @@ export function registerOnNewMention(bot: ContentAgentBot) { captionLength, upscale, githubRepo, + ...(songs && songs.length > 0 && { songs }), }; const results = await Promise.allSettled( From 1871d4241d1bebcfa3ee99128bee320eaa374c18 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 18:11:35 -0500 Subject: [PATCH 2/6] refactor: extract shared songsSchema for DRY Both validateCreateContentBody and createContentPromptAgent now import from a single songsSchema definition. Validation rules only need to change in one place. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content/createContentPromptAgent.ts | 10 +++---- lib/content/__tests__/songsSchema.test.ts | 26 +++++++++++++++++++ lib/content/songsSchema.ts | 4 +++ lib/content/validateCreateContentBody.ts | 3 ++- 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 lib/content/__tests__/songsSchema.test.ts create mode 100644 lib/content/songsSchema.ts diff --git a/lib/agents/content/createContentPromptAgent.ts b/lib/agents/content/createContentPromptAgent.ts index efe22088..e16cc2db 100644 --- a/lib/agents/content/createContentPromptAgent.ts +++ b/lib/agents/content/createContentPromptAgent.ts @@ -2,6 +2,7 @@ import { Output, ToolLoopAgent, stepCountIs } from "ai"; import { z } from "zod"; import { LIGHTWEIGHT_MODEL } from "@/lib/const"; import { CONTENT_TEMPLATES, DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; +import { songsSchema } from "@/lib/content/songsSchema"; const templateNames = CONTENT_TEMPLATES.map(t => t.name) as [string, ...string[]]; @@ -30,12 +31,9 @@ export const contentPromptFlagsSchema = z.object({ "Whether to upscale for higher quality. True when the prompt mentions high quality, HD, upscale, 4K, or premium.", ), template: z.enum(templateNames).describe("Which visual template/scene to use for the video."), - songs: z - .array(z.string().min(1)) - .optional() - .describe( - "Song names or slugs mentioned in the prompt. Extract from phrases like 'the hiccups song', 'use track X', 'for song Y'. Omit if no specific songs are mentioned.", - ), + songs: songsSchema.describe( + "Song names or slugs mentioned in the prompt. Extract from phrases like 'the hiccups song', 'use track X', 'for song Y'. Omit if no specific songs are mentioned.", + ), }); export type ContentPromptFlags = z.infer; diff --git a/lib/content/__tests__/songsSchema.test.ts b/lib/content/__tests__/songsSchema.test.ts new file mode 100644 index 00000000..d8517f50 --- /dev/null +++ b/lib/content/__tests__/songsSchema.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { songsSchema } from "../songsSchema"; + +describe("songsSchema", () => { + it("accepts a valid array of song slugs", () => { + const result = songsSchema.safeParse(["hiccups", "adhd"]); + expect(result.success).toBe(true); + expect(result.data).toEqual(["hiccups", "adhd"]); + }); + + it("is optional (accepts undefined)", () => { + const result = songsSchema.safeParse(undefined); + expect(result.success).toBe(true); + expect(result.data).toBeUndefined(); + }); + + it("rejects empty strings", () => { + const result = songsSchema.safeParse([""]); + expect(result.success).toBe(false); + }); + + it("rejects non-string elements", () => { + const result = songsSchema.safeParse([123]); + expect(result.success).toBe(false); + }); +}); diff --git a/lib/content/songsSchema.ts b/lib/content/songsSchema.ts new file mode 100644 index 00000000..750780bf --- /dev/null +++ b/lib/content/songsSchema.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +/** Shared schema for the optional songs filter — used by both the API and the content prompt agent. */ +export const songsSchema = z.array(z.string().min(1)).optional(); diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 81585244..4937ef9c 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -9,6 +9,7 @@ import { isSupportedContentTemplate, } from "@/lib/content/contentTemplates"; import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; +import { songsSchema } from "@/lib/content/songsSchema"; export const CAPTION_LENGTHS = ["short", "medium", "long"] as const; @@ -25,7 +26,7 @@ export const createContentBodySchema = z.object({ caption_length: z.enum(CAPTION_LENGTHS).optional().default("short"), upscale: z.boolean().optional().default(false), batch: z.number().int().min(1).max(30).optional().default(1), - songs: z.array(z.string().min(1)).optional(), + songs: songsSchema, }); export type ValidatedCreateContentBody = { From 678803abcffed311f6e84241f272d5e966915c5a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 18:22:52 -0500 Subject: [PATCH 3/6] refactor: extract shared CAPTION_LENGTHS constant Moves CAPTION_LENGTHS to lib/content/captionLengths.ts so both validateCreateContentBody and createContentPromptAgent share the same source of truth. Re-exported from validateCreateContentBody for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/agents/content/createContentPromptAgent.ts | 3 ++- lib/content/__tests__/captionLengths.test.ts | 8 ++++++++ lib/content/captionLengths.ts | 2 ++ lib/content/validateCreateContentBody.ts | 4 +++- 4 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 lib/content/__tests__/captionLengths.test.ts create mode 100644 lib/content/captionLengths.ts diff --git a/lib/agents/content/createContentPromptAgent.ts b/lib/agents/content/createContentPromptAgent.ts index e16cc2db..52711707 100644 --- a/lib/agents/content/createContentPromptAgent.ts +++ b/lib/agents/content/createContentPromptAgent.ts @@ -2,6 +2,7 @@ import { Output, ToolLoopAgent, stepCountIs } from "ai"; import { z } from "zod"; import { LIGHTWEIGHT_MODEL } from "@/lib/const"; import { CONTENT_TEMPLATES, DEFAULT_CONTENT_TEMPLATE } from "@/lib/content/contentTemplates"; +import { CAPTION_LENGTHS } from "@/lib/content/captionLengths"; import { songsSchema } from "@/lib/content/songsSchema"; const templateNames = CONTENT_TEMPLATES.map(t => t.name) as [string, ...string[]]; @@ -21,7 +22,7 @@ export const contentPromptFlagsSchema = z.object({ "How many videos to generate. Extract from phrases like '3 videos', 'a few' (3), 'several' (5). Default 1.", ), captionLength: z - .enum(["short", "medium", "long"]) + .enum(CAPTION_LENGTHS) .describe( "Caption length: 'short' (default), 'medium', or 'long'. Extract from phrases like 'long caption', 'detailed text', 'brief caption'.", ), diff --git a/lib/content/__tests__/captionLengths.test.ts b/lib/content/__tests__/captionLengths.test.ts new file mode 100644 index 00000000..ed148cfa --- /dev/null +++ b/lib/content/__tests__/captionLengths.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import { CAPTION_LENGTHS } from "../captionLengths"; + +describe("CAPTION_LENGTHS", () => { + it("contains exactly short, medium, long", () => { + expect(CAPTION_LENGTHS).toEqual(["short", "medium", "long"]); + }); +}); diff --git a/lib/content/captionLengths.ts b/lib/content/captionLengths.ts new file mode 100644 index 00000000..1a3ecd84 --- /dev/null +++ b/lib/content/captionLengths.ts @@ -0,0 +1,2 @@ +export const CAPTION_LENGTHS = ["short", "medium", "long"] as const; +export type CaptionLength = (typeof CAPTION_LENGTHS)[number]; diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 4937ef9c..6495970d 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -11,7 +11,9 @@ import { import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; import { songsSchema } from "@/lib/content/songsSchema"; -export const CAPTION_LENGTHS = ["short", "medium", "long"] as const; +import { CAPTION_LENGTHS } from "@/lib/content/captionLengths"; + +export { CAPTION_LENGTHS }; export const createContentBodySchema = z.object({ artist_account_id: z From e8aba0db91d52a41724fa6d0cc5684367f721f00 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 18:29:33 -0500 Subject: [PATCH 4/6] refactor: remove unnecessary CAPTION_LENGTHS re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing imports CAPTION_LENGTHS from validateCreateContentBody — the re-export was YAGNI. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/content/validateCreateContentBody.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 6495970d..e1e8c343 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -13,8 +13,6 @@ import { songsSchema } from "@/lib/content/songsSchema"; import { CAPTION_LENGTHS } from "@/lib/content/captionLengths"; -export { CAPTION_LENGTHS }; - export const createContentBodySchema = z.object({ artist_account_id: z .string({ message: "artist_account_id is required" }) From c27063ebbf2305c633aa22a54581c72247c9a874 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 18:34:33 -0500 Subject: [PATCH 5/6] feat: include song names in Slack acknowledgment message When songs are specified, the ack message now shows which songs will be used (e.g. "using hiccups, adhd"). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/registerOnNewMention.test.ts | 27 +++++++++++++++++++ .../content/handlers/registerOnNewMention.ts | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/agents/content/__tests__/registerOnNewMention.test.ts b/lib/agents/content/__tests__/registerOnNewMention.test.ts index 3b0a09dd..fa3e7e26 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -271,4 +271,31 @@ describe("registerOnNewMention", () => { expect(ackMessage).toContain("(2 videos)"); expect(ackMessage).toContain("test-artist"); }); + + it("includes song names in acknowledgment message", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + songs: ["hiccups"], + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("test-artist"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make a video for hiccups"); + await bot.getHandler()!(thread, message); + + const ackMessage = thread.post.mock.calls[0][0] as string; + expect(ackMessage).toContain("hiccups"); + }); }); diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 18bc396b..d076a0ee 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -58,8 +58,9 @@ export function registerOnNewMention(bot: ContentAgentBot) { // Post acknowledgment const batchNote = batch > 1 ? ` (${batch} videos)` : ""; const lipsyncNote = lipsync ? " with lipsync" : ""; + const songsNote = songs && songs.length > 0 ? ` using ${songs.join(", ")}` : ""; await thread.post( - `Generating content for **${artistSlug}**${batchNote}${lipsyncNote}... Template: \`${template}\`. I'll reply here when ready (~5-10 min).`, + `Generating content for **${artistSlug}**${batchNote}${lipsyncNote}${songsNote}... Template: \`${template}\`. I'll reply here when ready (~5-10 min).`, ); // Trigger content creation From 4970281ea39b6f99b9c01e14d26b1fad903d54e9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 30 Mar 2026 18:37:18 -0500 Subject: [PATCH 6/6] feat: improve Slack acknowledgment formatting Use Slack-compatible markdown (*bold* not **bold**) and bullet list for details including song names, template, lipsync, and video count. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content/__tests__/registerOnNewMention.test.ts | 8 +++++--- .../content/handlers/registerOnNewMention.ts | 14 ++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/agents/content/__tests__/registerOnNewMention.test.ts b/lib/agents/content/__tests__/registerOnNewMention.test.ts index fa3e7e26..9f1a147c 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -267,9 +267,10 @@ describe("registerOnNewMention", () => { await bot.getHandler()!(thread, message); const ackMessage = thread.post.mock.calls[0][0] as string; - expect(ackMessage).toContain("with lipsync"); - expect(ackMessage).toContain("(2 videos)"); - expect(ackMessage).toContain("test-artist"); + expect(ackMessage).toContain("*test-artist*"); + expect(ackMessage).not.toContain("**"); + expect(ackMessage).toContain("Lipsync:"); + expect(ackMessage).toContain("Videos:"); }); it("includes song names in acknowledgment message", async () => { @@ -296,6 +297,7 @@ describe("registerOnNewMention", () => { await bot.getHandler()!(thread, message); const ackMessage = thread.post.mock.calls[0][0] as string; + expect(ackMessage).toContain("Songs:"); expect(ackMessage).toContain("hiccups"); }); }); diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index d076a0ee..d5e4b706 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -56,11 +56,17 @@ export function registerOnNewMention(bot: ContentAgentBot) { } // Post acknowledgment - const batchNote = batch > 1 ? ` (${batch} videos)` : ""; - const lipsyncNote = lipsync ? " with lipsync" : ""; - const songsNote = songs && songs.length > 0 ? ` using ${songs.join(", ")}` : ""; + const details = [ + `- Artist: *${artistSlug}*`, + `- Template: ${template}`, + `- Videos: ${batch}`, + `- Lipsync: ${lipsync ? "yes" : "no"}`, + ]; + if (songs && songs.length > 0) { + details.push(`- Songs: ${songs.join(", ")}`); + } await thread.post( - `Generating content for **${artistSlug}**${batchNote}${lipsyncNote}${songsNote}... Template: \`${template}\`. I'll reply here when ready (~5-10 min).`, + `Generating content...\n${details.join("\n")}\n\nI'll reply here when ready (~5-10 min).`, ); // Trigger content creation