Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions lib/agents/content/__tests__/parseContentPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
89 changes: 86 additions & 3 deletions lib/agents/content/__tests__/registerOnNewMention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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");
});
});
7 changes: 6 additions & 1 deletion lib/agents/content/createContentPromptAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]];

Expand All @@ -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'.",
),
Expand All @@ -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<typeof contentPromptFlagsSchema>;
Expand Down
16 changes: 12 additions & 4 deletions lib/agents/content/handlers/registerOnNewMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down Expand Up @@ -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
Expand All @@ -71,6 +78,7 @@ export function registerOnNewMention(bot: ContentAgentBot) {
captionLength,
upscale,
githubRepo,
...(songs && songs.length > 0 && { songs }),
};

const results = await Promise.allSettled(
Expand Down
8 changes: 8 additions & 0 deletions lib/content/__tests__/captionLengths.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
26 changes: 26 additions & 0 deletions lib/content/__tests__/songsSchema.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions lib/content/captionLengths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const CAPTION_LENGTHS = ["short", "medium", "long"] as const;
export type CaptionLength = (typeof CAPTION_LENGTHS)[number];
4 changes: 4 additions & 0 deletions lib/content/songsSchema.ts
Original file line number Diff line number Diff line change
@@ -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();
5 changes: 3 additions & 2 deletions lib/content/validateCreateContentBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down
Loading