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..9f1a147c 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); @@ -213,8 +267,37 @@ 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 () => { + 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("Songs:"); + expect(ackMessage).toContain("hiccups"); }); }); diff --git a/lib/agents/content/createContentPromptAgent.ts b/lib/agents/content/createContentPromptAgent.ts index 8f5c6a14..52711707 100644 --- a/lib/agents/content/createContentPromptAgent.ts +++ b/lib/agents/content/createContentPromptAgent.ts @@ -2,6 +2,8 @@ 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[]]; @@ -20,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'.", ), @@ -30,6 +32,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: 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/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index 8a51693c..d5e4b706 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, ); @@ -56,10 +56,17 @@ export function registerOnNewMention(bot: ContentAgentBot) { } // Post acknowledgment - const batchNote = batch > 1 ? ` (${batch} videos)` : ""; - const lipsyncNote = lipsync ? " with lipsync" : ""; + 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}... 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 @@ -71,6 +78,7 @@ export function registerOnNewMention(bot: ContentAgentBot) { captionLength, upscale, githubRepo, + ...(songs && songs.length > 0 && { songs }), }; const results = await Promise.allSettled( 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/__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/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/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..e1e8c343 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -9,8 +9,9 @@ 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; +import { CAPTION_LENGTHS } from "@/lib/content/captionLengths"; export const createContentBodySchema = z.object({ artist_account_id: z @@ -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 = {