Skip to content
Open
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 @@ -188,4 +188,35 @@ describe("parseContentPrompt", () => {

expect(result.songs).toBeUndefined();
});

it("extracts artistName from prompt when an artist is mentioned", async () => {
const flags: ContentPromptFlags = {
lipsync: false,
batch: 1,
captionLength: "short",
upscale: false,
template: "artist-caption-bedroom",
artistName: "Mac Miller",
};
mockGenerate.mockResolvedValue({ output: flags });

const result = await parseContentPrompt("generate a video for Mac Miller");

expect(result.artistName).toBe("Mac Miller");
});

it("returns undefined artistName when no artist is 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.artistName).toBeUndefined();
});
});
94 changes: 94 additions & 0 deletions lib/agents/content/__tests__/registerOnNewMention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ vi.mock("../parseContentPrompt", () => ({
parseContentPrompt: vi.fn(),
}));

vi.mock("../resolveArtistFromName", () => ({
resolveArtistFromName: vi.fn(),
}));

const { triggerCreateContent } = await import("@/lib/trigger/triggerCreateContent");
const { triggerPollContentRun } = await import("@/lib/trigger/triggerPollContentRun");
const { resolveArtistSlug } = await import("@/lib/content/resolveArtistSlug");
const { getArtistContentReadiness } = await import("@/lib/content/getArtistContentReadiness");
const { parseContentPrompt } = await import("../parseContentPrompt");
const { resolveArtistFromName } = await import("../resolveArtistFromName");

/**
* Creates a mock content agent bot for testing.
Expand Down Expand Up @@ -300,4 +305,93 @@ describe("registerOnNewMention", () => {
expect(ackMessage).toContain("Songs:");
expect(ackMessage).toContain("hiccups");
});

it("uses resolved artist when artistName is provided in prompt", async () => {
const bot = createMockBot();
registerOnNewMention(bot as never);

vi.mocked(parseContentPrompt).mockResolvedValue({
lipsync: false,
batch: 1,
captionLength: "short",
upscale: false,
template: "artist-caption-bedroom",
artistName: "Mac Miller",
});
vi.mocked(resolveArtistFromName).mockResolvedValue("bbb-artist-id");
vi.mocked(resolveArtistSlug).mockResolvedValue("mac-miller");
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("generate a video for Mac Miller");
await bot.getHandler()!(thread, message);

expect(resolveArtistFromName).toHaveBeenCalledWith(
"Mac Miller",
"fb678396-a68f-4294-ae50-b8cacf9ce77b",
);
expect(resolveArtistSlug).toHaveBeenCalledWith("bbb-artist-id");
expect(triggerCreateContent).toHaveBeenCalledWith(
expect.objectContaining({ artistSlug: "mac-miller" }),
);
});

it("falls back to default artist when artistName is not provided", 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("gatsby-grace");
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);

expect(resolveArtistFromName).not.toHaveBeenCalled();
expect(resolveArtistSlug).toHaveBeenCalledWith("1873859c-dd37-4e9a-9bac-80d3558527a9");
});

it("falls back to default artist when resolveArtistFromName returns null", async () => {
const bot = createMockBot();
registerOnNewMention(bot as never);

vi.mocked(parseContentPrompt).mockResolvedValue({
lipsync: false,
batch: 1,
captionLength: "short",
upscale: false,
template: "artist-caption-bedroom",
artistName: "Unknown Artist",
});
vi.mocked(resolveArtistFromName).mockResolvedValue(null);
vi.mocked(resolveArtistSlug).mockResolvedValue("gatsby-grace");
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("generate a video for Unknown Artist");
await bot.getHandler()!(thread, message);

expect(resolveArtistSlug).toHaveBeenCalledWith("1873859c-dd37-4e9a-9bac-80d3558527a9");
const ackMessage = thread.post.mock.calls[0][0] as string;
expect(ackMessage).toContain("*gatsby-grace*");
});
});
80 changes: 80 additions & 0 deletions lib/agents/content/__tests__/resolveArtistFromName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveArtistFromName } from "../resolveArtistFromName";

vi.mock("@/lib/supabase/account_artist_ids/getAccountArtistIds", () => ({
getAccountArtistIds: vi.fn(),
}));

const { getAccountArtistIds } = await import(
"@/lib/supabase/account_artist_ids/getAccountArtistIds"
);

/**
*
* @param name
* @param artistId
*/
function createArtistRow(name: string, artistId: string) {
return {
artist_id: artistId,
pinned: false,
artist_info: { name, id: artistId },
};
}

describe("resolveArtistFromName", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns the matching artist_id for an exact name match (case-insensitive)", async () => {
vi.mocked(getAccountArtistIds).mockResolvedValue([
createArtistRow("Gatsby Grace", "aaa"),
createArtistRow("Mac Miller", "bbb"),
] as never);

const result = await resolveArtistFromName("mac miller", "account-1");
expect(result).toBe("bbb");
});

it("returns the closest match when the name is a partial match", async () => {
vi.mocked(getAccountArtistIds).mockResolvedValue([
createArtistRow("Gatsby Grace", "aaa"),
createArtistRow("Mac Miller", "bbb"),
] as never);

const result = await resolveArtistFromName("Mac", "account-1");
expect(result).toBe("bbb");
});

it("returns null when no artists match", async () => {
vi.mocked(getAccountArtistIds).mockResolvedValue([
createArtistRow("Gatsby Grace", "aaa"),
] as never);

const result = await resolveArtistFromName("Unknown Artist", "account-1");
expect(result).toBeNull();
});

it("returns null when the account has no artists", async () => {
vi.mocked(getAccountArtistIds).mockResolvedValue([]);

const result = await resolveArtistFromName("Mac Miller", "account-1");
expect(result).toBeNull();
});

it("returns null when artistName is empty", async () => {
const result = await resolveArtistFromName("", "account-1");
expect(result).toBeNull();
expect(getAccountArtistIds).not.toHaveBeenCalled();
});

it("queries getAccountArtistIds with the correct accountId", async () => {
vi.mocked(getAccountArtistIds).mockResolvedValue([
createArtistRow("Gatsby Grace", "aaa"),
] as never);

await resolveArtistFromName("Gatsby", "account-42");
expect(getAccountArtistIds).toHaveBeenCalledWith({ accountIds: ["account-42"] });
});
});
6 changes: 6 additions & 0 deletions lib/agents/content/createContentPromptAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export const contentPromptFlagsSchema = z.object({
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.",
),
artistName: z
.string()
.optional()
.describe(
"The artist name mentioned in the prompt. Extract from phrases like 'for Mac Miller', 'generate a video for [artist]', 'make content for [artist]'. Omit if no specific artist is mentioned.",
),
});

export type ContentPromptFlags = z.infer<typeof contentPromptFlagsSchema>;
Expand Down
21 changes: 16 additions & 5 deletions lib/agents/content/handlers/registerOnNewMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug";
import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness";
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
import { parseContentPrompt } from "../parseContentPrompt";
import { resolveArtistFromName } from "../resolveArtistFromName";

const DEFAULT_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b";
const DEFAULT_ARTIST_ACCOUNT_ID = "1873859c-dd37-4e9a-9bac-80d3558527a9";

/**
* Registers the onNewMention handler on the content agent bot.
Expand All @@ -17,13 +21,20 @@ import { parseContentPrompt } from "../parseContentPrompt";
export function registerOnNewMention(bot: ContentAgentBot) {
bot.onNewMention(async (thread, message) => {
try {
const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b";
const artistAccountId = "1873859c-dd37-4e9a-9bac-80d3558527a9";
const accountId = DEFAULT_ACCOUNT_ID;

// Parse the user's natural-language prompt into structured flags
const { lipsync, batch, captionLength, upscale, template, songs } = await parseContentPrompt(
message.text,
);
const { lipsync, batch, captionLength, upscale, template, songs, artistName } =
await parseContentPrompt(message.text);

// Resolve artist account ID from name, or fall back to default
let artistAccountId = DEFAULT_ARTIST_ACCOUNT_ID;
if (artistName) {
const resolvedId = await resolveArtistFromName(artistName, accountId);
if (resolvedId) {
artistAccountId = resolvedId;
}
}

// Resolve artist slug
const artistSlug = await resolveArtistSlug(artistAccountId);
Expand Down
40 changes: 40 additions & 0 deletions lib/agents/content/resolveArtistFromName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccountArtistIds";

/**
* Resolves an artist name to an artist_account_id by searching
* the account's artist roster for the closest match.
*
* @param artistName - The artist name extracted from the prompt
* @param accountId - The workspace account ID to search within
* @returns The matching artist_id, or null if no match is found
*/
export async function resolveArtistFromName(
artistName: string,
accountId: string,
): Promise<string | null> {
if (!artistName) return null;

const artists = await getAccountArtistIds({ accountIds: [accountId] });
if (!artists || artists.length === 0) return null;

const query = artistName.toLowerCase();

// Try exact match first (case-insensitive)
const exactMatch = artists.find(
a =>
(a as unknown as { artist_info: { name: string } }).artist_info?.name?.toLowerCase() ===
query,
);
if (exactMatch) return exactMatch.artist_id;

// Try prefix/includes match
const partialMatch = artists.find(a => {
const name = (
a as unknown as { artist_info: { name: string } }
).artist_info?.name?.toLowerCase();
return name?.includes(query) || query.includes(name ?? "");
});
if (partialMatch) return partialMatch.artist_id;

return null;
}
Loading