From 7b0f463c9b87bda38e0e9573447d86e3ee11067c Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Wed, 1 Apr 2026 18:22:31 +0000 Subject: [PATCH 1/3] feat: embed videos inline in Slack instead of posting URLs When the content agent finishes generating a video, download it and post it as a file upload so it appears inline in the Slack thread. Falls back to the old URL-link behavior if the download fails. - New downloadVideoBuffer utility to fetch video data - Updated handleContentAgentCallback to use thread.post({ files }) - 14 new tests (4 downloadVideoBuffer + 10 callback handler) Co-Authored-By: Paperclip --- .../__tests__/downloadVideoBuffer.test.ts | 47 +++++ .../handleContentAgentCallback.test.ts | 184 ++++++++++++++++++ lib/agents/content/downloadVideoBuffer.ts | 22 +++ .../content/handleContentAgentCallback.ts | 54 ++++- 4 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 lib/agents/content/__tests__/downloadVideoBuffer.test.ts create mode 100644 lib/agents/content/downloadVideoBuffer.ts diff --git a/lib/agents/content/__tests__/downloadVideoBuffer.test.ts b/lib/agents/content/__tests__/downloadVideoBuffer.test.ts new file mode 100644 index 00000000..ae6549cf --- /dev/null +++ b/lib/agents/content/__tests__/downloadVideoBuffer.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { downloadVideoBuffer } from "../downloadVideoBuffer"; + +describe("downloadVideoBuffer", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it("returns a Buffer with the video data on success", async () => { + const fakeData = new Uint8Array([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(fakeData, { + status: 200, + headers: { "content-type": "video/mp4" }, + }), + ); + + const result = await downloadVideoBuffer("https://example.com/video.mp4"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBe(fakeData.length); + }); + + it("returns null when fetch returns a non-ok status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 })); + + const result = await downloadVideoBuffer("https://example.com/missing.mp4"); + expect(result).toBeNull(); + }); + + it("returns null when fetch throws a network error", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); + + const result = await downloadVideoBuffer("https://example.com/video.mp4"); + expect(result).toBeNull(); + }); + + it("extracts the filename from the URL path", async () => { + const fakeData = new Uint8Array([0x01, 0x02]); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(fakeData, { status: 200 })); + + const result = await downloadVideoBuffer( + "https://cdn.example.com/path/to/my-video.mp4?token=abc", + ); + expect(result).toBeInstanceOf(Buffer); + }); +}); diff --git a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts index c334ae8d..0cfcce06 100644 --- a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts +++ b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts @@ -13,6 +13,18 @@ vi.mock("@/lib/agents/getThread", () => ({ getThread: vi.fn(), })); +vi.mock("../downloadVideoBuffer", () => ({ + downloadVideoBuffer: vi.fn(), +})); + +const { validateContentAgentCallback } = await import("../validateContentAgentCallback"); +const { getThread } = await import("@/lib/agents/getThread"); +const { downloadVideoBuffer } = await import("../downloadVideoBuffer"); + +const mockedValidate = vi.mocked(validateContentAgentCallback); +const mockedGetThread = vi.mocked(getThread); +const mockedDownload = vi.mocked(downloadVideoBuffer); + describe("handleContentAgentCallback", () => { const originalEnv = { ...process.env }; @@ -70,4 +82,176 @@ describe("handleContentAgentCallback", () => { // Should get past auth and fail on invalid JSON (400), not auth (401) expect(response.status).toBe(400); }); + + describe("completed callback with videos", () => { + /** + * Creates a Request with a valid auth header for testing. + * + * @param body - The request body to serialize as JSON + * @returns A Request instance with valid auth headers + */ + function makeAuthRequest(body: object) { + return new Request("http://localhost/api/content-agent/callback", { + method: "POST", + headers: { "x-callback-secret": "test-secret" }, + body: JSON.stringify(body), + }); + } + + /** + * Creates a mock thread with post, state, and setState. + * + * @returns A mock thread object + */ + function mockThread() { + const thread = { + post: vi.fn().mockResolvedValue(undefined), + state: Promise.resolve({ status: "running" }), + setState: vi.fn().mockResolvedValue(undefined), + }; + mockedGetThread.mockReturnValue(thread as never); + return thread; + } + + it("posts video as file upload when download succeeds", async () => { + const thread = mockThread(); + const videoData = Buffer.from([0x00, 0x00, 0x00, 0x1c]); + mockedDownload.mockResolvedValue(videoData); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [ + { + runId: "run-1", + status: "completed", + videoUrl: "https://cdn.example.com/video.mp4", + captionText: "Test caption", + }, + ], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + + expect(response.status).toBe(200); + expect(mockedDownload).toHaveBeenCalledWith("https://cdn.example.com/video.mp4"); + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + markdown: expect.stringContaining("Test caption"), + files: [ + expect.objectContaining({ + data: videoData, + filename: "video.mp4", + mimeType: "video/mp4", + }), + ], + }), + ); + }); + + it("falls back to posting video URL when download fails", async () => { + const thread = mockThread(); + mockedDownload.mockResolvedValue(null); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [ + { + runId: "run-1", + status: "completed", + videoUrl: "https://cdn.example.com/video.mp4", + }, + ], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + + expect(response.status).toBe(200); + expect(thread.post).toHaveBeenCalledWith( + expect.stringContaining("https://cdn.example.com/video.mp4"), + ); + }); + + it("handles multiple videos with individual file uploads", async () => { + const thread = mockThread(); + const videoData1 = Buffer.from([0x01]); + const videoData2 = Buffer.from([0x02]); + mockedDownload.mockResolvedValueOnce(videoData1).mockResolvedValueOnce(videoData2); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [ + { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/video1.mp4" }, + { runId: "run-2", status: "completed", videoUrl: "https://cdn.example.com/video2.mp4" }, + ], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + + expect(response.status).toBe(200); + expect(thread.post).toHaveBeenCalledTimes(2); + }); + + it("posts without caption when captionText is absent", async () => { + const thread = mockThread(); + const videoData = Buffer.from([0x01]); + mockedDownload.mockResolvedValue(videoData); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [ + { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/clip.mp4" }, + ], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + + expect(response.status).toBe(200); + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + files: [expect.objectContaining({ filename: "clip.mp4" })], + }), + ); + }); + + it("skips duplicate delivery when thread status is not running", async () => { + const thread = { + post: vi.fn(), + state: Promise.resolve({ status: "completed" }), + setState: vi.fn(), + }; + mockedGetThread.mockReturnValue(thread as never); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + const body = await response.json(); + + expect(body.skipped).toBe(true); + expect(thread.post).not.toHaveBeenCalled(); + }); + + it("appends failed run count when some runs fail", async () => { + const thread = mockThread(); + const videoData = Buffer.from([0x01]); + mockedDownload.mockResolvedValue(videoData); + mockedValidate.mockReturnValue({ + threadId: "slack:C123:T456", + status: "completed", + results: [ + { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/video.mp4" }, + { runId: "run-2", status: "failed", error: "render error" }, + ], + }); + + const response = await handleContentAgentCallback(makeAuthRequest({})); + + expect(response.status).toBe(200); + // Last post should mention failures + const lastCall = thread.post.mock.calls[thread.post.mock.calls.length - 1][0]; + expect(typeof lastCall === "string" ? lastCall : "").toContain("failed"); + }); + }); }); diff --git a/lib/agents/content/downloadVideoBuffer.ts b/lib/agents/content/downloadVideoBuffer.ts new file mode 100644 index 00000000..e6694431 --- /dev/null +++ b/lib/agents/content/downloadVideoBuffer.ts @@ -0,0 +1,22 @@ +/** + * Downloads a video from a URL and returns the data as a Buffer. + * + * @param url - The video URL to download + * @returns The video data as a Buffer, or null if the download fails + */ +export async function downloadVideoBuffer(url: string): Promise { + try { + const response = await fetch(url); + + if (!response.ok) { + console.error(`Failed to download video: HTTP ${response.status} from ${url}`); + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch (error) { + console.error("Failed to download video:", error); + return null; + } +} diff --git a/lib/agents/content/handleContentAgentCallback.ts b/lib/agents/content/handleContentAgentCallback.ts index de01f754..bff22d3f 100644 --- a/lib/agents/content/handleContentAgentCallback.ts +++ b/lib/agents/content/handleContentAgentCallback.ts @@ -3,8 +3,26 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateContentAgentCallback } from "./validateContentAgentCallback"; import { getThread } from "@/lib/agents/getThread"; +import { downloadVideoBuffer } from "./downloadVideoBuffer"; import type { ContentAgentThreadState } from "./types"; +/** + * Extracts the filename from a URL path, falling back to "video.mp4". + * + * @param url - The video URL + * @returns The extracted filename + */ +function getFilenameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const segments = pathname.split("/"); + const last = segments[segments.length - 1]; + return last && last.includes(".") ? last : "video.mp4"; + } catch { + return "video.mp4"; + } +} + /** * Handles content agent task callback from Trigger.dev. * Verifies the shared secret and dispatches based on callback status. @@ -62,17 +80,37 @@ export async function handleContentAgentCallback(request: Request): Promise r.status === "failed"); if (videos.length > 0) { - const lines = videos.map((v, i) => { - const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; - const caption = v.captionText ? `\n> ${v.captionText}` : ""; - return `${label}${v.videoUrl}${caption}`; - }); + for (let i = 0; i < videos.length; i++) { + const v = videos[i]; + const videoBuffer = await downloadVideoBuffer(v.videoUrl!); + + if (videoBuffer) { + const filename = getFilenameFromUrl(v.videoUrl!); + const label = videos.length > 1 ? `**Video ${i + 1}**` : ""; + const caption = v.captionText ? `> ${v.captionText}` : ""; + const markdown = [label, caption].filter(Boolean).join("\n"); + + await thread.post({ + markdown: markdown || filename, + files: [ + { + data: videoBuffer, + filename, + mimeType: "video/mp4", + }, + ], + }); + } else { + // Fallback to URL link if download fails + const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; + const caption = v.captionText ? `\n> ${v.captionText}` : ""; + await thread.post(`${label}${v.videoUrl}${caption}`); + } + } if (failed.length > 0) { - lines.push(`\n_${failed.length} run(s) failed._`); + await thread.post(`_${failed.length} run(s) failed._`); } - - await thread.post(lines.join("\n\n")); } else { await thread.post("Content generation finished but no videos were produced."); } From fb4f0faeb8b17a64bd518995e0043987b6804267 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 1 Apr 2026 13:36:16 -0500 Subject: [PATCH 2/3] refactor: extract getFilenameFromUrl and postVideoResults, parallelize downloads - Extract getFilenameFromUrl into its own file with tests for edge cases (encoded chars, no extension, fal.ai URLs, query params) - Extract video embed loop into postVideoResults (SRP/OCP) - Download all videos in parallel with Promise.all instead of sequentially, then post to Slack in order - Update handleContentAgentCallback tests to mock postVideoResults Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/getFilenameFromUrl.test.ts | 40 ++++++ .../handleContentAgentCallback.test.ts | 117 +++--------------- .../__tests__/postVideoResults.test.ts | 117 ++++++++++++++++++ lib/agents/content/getFilenameFromUrl.ts | 16 +++ .../content/handleContentAgentCallback.ts | 51 +------- lib/agents/content/postVideoResults.ts | 64 ++++++++++ 6 files changed, 258 insertions(+), 147 deletions(-) create mode 100644 lib/agents/content/__tests__/getFilenameFromUrl.test.ts create mode 100644 lib/agents/content/__tests__/postVideoResults.test.ts create mode 100644 lib/agents/content/getFilenameFromUrl.ts create mode 100644 lib/agents/content/postVideoResults.ts diff --git a/lib/agents/content/__tests__/getFilenameFromUrl.test.ts b/lib/agents/content/__tests__/getFilenameFromUrl.test.ts new file mode 100644 index 00000000..b60440ca --- /dev/null +++ b/lib/agents/content/__tests__/getFilenameFromUrl.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { getFilenameFromUrl } from "../getFilenameFromUrl"; + +describe("getFilenameFromUrl", () => { + it("extracts filename from a simple URL", () => { + expect(getFilenameFromUrl("https://cdn.example.com/path/to/video.mp4")).toBe("video.mp4"); + }); + + it("extracts filename from URL with query params", () => { + expect(getFilenameFromUrl("https://cdn.example.com/video.mp4?token=abc&t=123")).toBe( + "video.mp4", + ); + }); + + it("handles URL-encoded characters", () => { + expect( + getFilenameFromUrl("https://cdn.example.com/my%20video%20file.mp4"), + ).toBe("my%20video%20file.mp4"); + }); + + it("falls back to video.mp4 when URL has no extension", () => { + expect(getFilenameFromUrl("https://cdn.example.com/path/to/video")).toBe("video.mp4"); + }); + + it("falls back to video.mp4 when URL path ends with slash", () => { + expect(getFilenameFromUrl("https://cdn.example.com/path/")).toBe("video.mp4"); + }); + + it("falls back to video.mp4 for invalid URLs", () => { + expect(getFilenameFromUrl("not-a-url")).toBe("video.mp4"); + }); + + it("handles fal.ai storage URLs", () => { + expect( + getFilenameFromUrl( + "https://v3b.fal.media/files/b/0a9486c8/sjfeqG-MFh_3aG213aIU2_final-video.mp4", + ), + ).toBe("sjfeqG-MFh_3aG213aIU2_final-video.mp4"); + }); +}); diff --git a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts index 0cfcce06..592374f5 100644 --- a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts +++ b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts @@ -13,17 +13,17 @@ vi.mock("@/lib/agents/getThread", () => ({ getThread: vi.fn(), })); -vi.mock("../downloadVideoBuffer", () => ({ - downloadVideoBuffer: vi.fn(), +vi.mock("../postVideoResults", () => ({ + postVideoResults: vi.fn().mockResolvedValue(undefined), })); const { validateContentAgentCallback } = await import("../validateContentAgentCallback"); const { getThread } = await import("@/lib/agents/getThread"); -const { downloadVideoBuffer } = await import("../downloadVideoBuffer"); +const { postVideoResults } = await import("../postVideoResults"); const mockedValidate = vi.mocked(validateContentAgentCallback); const mockedGetThread = vi.mocked(getThread); -const mockedDownload = vi.mocked(downloadVideoBuffer); +const mockedPostVideos = vi.mocked(postVideoResults); describe("handleContentAgentCallback", () => { const originalEnv = { ...process.env }; @@ -84,12 +84,6 @@ describe("handleContentAgentCallback", () => { }); describe("completed callback with videos", () => { - /** - * Creates a Request with a valid auth header for testing. - * - * @param body - The request body to serialize as JSON - * @returns A Request instance with valid auth headers - */ function makeAuthRequest(body: object) { return new Request("http://localhost/api/content-agent/callback", { method: "POST", @@ -98,11 +92,6 @@ describe("handleContentAgentCallback", () => { }); } - /** - * Creates a mock thread with post, state, and setState. - * - * @returns A mock thread object - */ function mockThread() { const thread = { post: vi.fn().mockResolvedValue(undefined), @@ -113,104 +102,42 @@ describe("handleContentAgentCallback", () => { return thread; } - it("posts video as file upload when download succeeds", async () => { + it("calls postVideoResults with videos and failed count", async () => { const thread = mockThread(); - const videoData = Buffer.from([0x00, 0x00, 0x00, 0x1c]); - mockedDownload.mockResolvedValue(videoData); mockedValidate.mockReturnValue({ threadId: "slack:C123:T456", status: "completed", results: [ - { - runId: "run-1", - status: "completed", - videoUrl: "https://cdn.example.com/video.mp4", - captionText: "Test caption", - }, + { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/video.mp4", captionText: "Test" }, + { runId: "run-2", status: "failed", error: "render error" }, ], }); const response = await handleContentAgentCallback(makeAuthRequest({})); expect(response.status).toBe(200); - expect(mockedDownload).toHaveBeenCalledWith("https://cdn.example.com/video.mp4"); - expect(thread.post).toHaveBeenCalledWith( - expect.objectContaining({ - markdown: expect.stringContaining("Test caption"), - files: [ - expect.objectContaining({ - data: videoData, - filename: "video.mp4", - mimeType: "video/mp4", - }), - ], - }), + expect(mockedPostVideos).toHaveBeenCalledWith( + thread, + [expect.objectContaining({ videoUrl: "https://cdn.example.com/video.mp4" })], + 1, ); }); - it("falls back to posting video URL when download fails", async () => { + it("posts fallback message when no videos produced", async () => { const thread = mockThread(); - mockedDownload.mockResolvedValue(null); mockedValidate.mockReturnValue({ threadId: "slack:C123:T456", status: "completed", - results: [ - { - runId: "run-1", - status: "completed", - videoUrl: "https://cdn.example.com/video.mp4", - }, - ], + results: [{ runId: "run-1", status: "failed", error: "render error" }], }); const response = await handleContentAgentCallback(makeAuthRequest({})); expect(response.status).toBe(200); expect(thread.post).toHaveBeenCalledWith( - expect.stringContaining("https://cdn.example.com/video.mp4"), - ); - }); - - it("handles multiple videos with individual file uploads", async () => { - const thread = mockThread(); - const videoData1 = Buffer.from([0x01]); - const videoData2 = Buffer.from([0x02]); - mockedDownload.mockResolvedValueOnce(videoData1).mockResolvedValueOnce(videoData2); - mockedValidate.mockReturnValue({ - threadId: "slack:C123:T456", - status: "completed", - results: [ - { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/video1.mp4" }, - { runId: "run-2", status: "completed", videoUrl: "https://cdn.example.com/video2.mp4" }, - ], - }); - - const response = await handleContentAgentCallback(makeAuthRequest({})); - - expect(response.status).toBe(200); - expect(thread.post).toHaveBeenCalledTimes(2); - }); - - it("posts without caption when captionText is absent", async () => { - const thread = mockThread(); - const videoData = Buffer.from([0x01]); - mockedDownload.mockResolvedValue(videoData); - mockedValidate.mockReturnValue({ - threadId: "slack:C123:T456", - status: "completed", - results: [ - { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/clip.mp4" }, - ], - }); - - const response = await handleContentAgentCallback(makeAuthRequest({})); - - expect(response.status).toBe(200); - expect(thread.post).toHaveBeenCalledWith( - expect.objectContaining({ - files: [expect.objectContaining({ filename: "clip.mp4" })], - }), + "Content generation finished but no videos were produced.", ); + expect(mockedPostVideos).not.toHaveBeenCalled(); }); it("skips duplicate delivery when thread status is not running", async () => { @@ -233,25 +160,19 @@ describe("handleContentAgentCallback", () => { expect(thread.post).not.toHaveBeenCalled(); }); - it("appends failed run count when some runs fail", async () => { + it("sets thread state to completed after posting", async () => { const thread = mockThread(); - const videoData = Buffer.from([0x01]); - mockedDownload.mockResolvedValue(videoData); mockedValidate.mockReturnValue({ threadId: "slack:C123:T456", status: "completed", results: [ - { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/video.mp4" }, - { runId: "run-2", status: "failed", error: "render error" }, + { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/v.mp4" }, ], }); - const response = await handleContentAgentCallback(makeAuthRequest({})); + await handleContentAgentCallback(makeAuthRequest({})); - expect(response.status).toBe(200); - // Last post should mention failures - const lastCall = thread.post.mock.calls[thread.post.mock.calls.length - 1][0]; - expect(typeof lastCall === "string" ? lastCall : "").toContain("failed"); + expect(thread.setState).toHaveBeenCalledWith({ status: "completed" }); }); }); }); diff --git a/lib/agents/content/__tests__/postVideoResults.test.ts b/lib/agents/content/__tests__/postVideoResults.test.ts new file mode 100644 index 00000000..33ba88c9 --- /dev/null +++ b/lib/agents/content/__tests__/postVideoResults.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { postVideoResults } from "../postVideoResults"; + +vi.mock("../downloadVideoBuffer", () => ({ + downloadVideoBuffer: vi.fn(), +})); + +const { downloadVideoBuffer } = await import("../downloadVideoBuffer"); +const mockedDownload = vi.mocked(downloadVideoBuffer); + +describe("postVideoResults", () => { + let thread: { post: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + thread = { post: vi.fn().mockResolvedValue(undefined) }; + }); + + it("downloads videos in parallel and posts each as a file upload", async () => { + const buf1 = Buffer.from([0x01]); + const buf2 = Buffer.from([0x02]); + mockedDownload.mockResolvedValueOnce(buf1).mockResolvedValueOnce(buf2); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" }, + { runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(mockedDownload).toHaveBeenCalledTimes(2); + expect(thread.post).toHaveBeenCalledTimes(2); + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + files: [expect.objectContaining({ filename: "v1.mp4" })], + }), + ); + }); + + it("falls back to URL link when download fails", async () => { + mockedDownload.mockResolvedValue(null); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledWith( + expect.stringContaining("https://cdn.example.com/v.mp4"), + ); + }); + + it("includes caption in markdown when present", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { + runId: "r1", + status: "completed" as const, + videoUrl: "https://cdn.example.com/v.mp4", + captionText: "great song", + }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + markdown: expect.stringContaining("great song"), + }), + ); + }); + + it("posts failed run count when failedCount > 0", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 2); + + const lastCall = thread.post.mock.calls[thread.post.mock.calls.length - 1][0]; + expect(lastCall).toContain("2"); + expect(lastCall).toContain("failed"); + }); + + it("does not post failed message when failedCount is 0", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledTimes(1); + }); + + it("labels videos when there are multiple", async () => { + mockedDownload.mockResolvedValue(Buffer.from([0x01])); + + const videos = [ + { runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" }, + { runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" }, + ]; + + await postVideoResults(thread as never, videos, 0); + + expect(thread.post).toHaveBeenCalledWith( + expect.objectContaining({ + markdown: expect.stringContaining("Video 1"), + }), + ); + }); +}); diff --git a/lib/agents/content/getFilenameFromUrl.ts b/lib/agents/content/getFilenameFromUrl.ts new file mode 100644 index 00000000..df3120ae --- /dev/null +++ b/lib/agents/content/getFilenameFromUrl.ts @@ -0,0 +1,16 @@ +/** + * Extracts the filename from a URL path, falling back to "video.mp4". + * + * @param url - The video URL + * @returns The extracted filename + */ +export function getFilenameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const segments = pathname.split("/"); + const last = segments[segments.length - 1]; + return last && last.includes(".") ? last : "video.mp4"; + } catch { + return "video.mp4"; + } +} diff --git a/lib/agents/content/handleContentAgentCallback.ts b/lib/agents/content/handleContentAgentCallback.ts index bff22d3f..52892dd3 100644 --- a/lib/agents/content/handleContentAgentCallback.ts +++ b/lib/agents/content/handleContentAgentCallback.ts @@ -3,26 +3,9 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateContentAgentCallback } from "./validateContentAgentCallback"; import { getThread } from "@/lib/agents/getThread"; -import { downloadVideoBuffer } from "./downloadVideoBuffer"; +import { postVideoResults } from "./postVideoResults"; import type { ContentAgentThreadState } from "./types"; -/** - * Extracts the filename from a URL path, falling back to "video.mp4". - * - * @param url - The video URL - * @returns The extracted filename - */ -function getFilenameFromUrl(url: string): string { - try { - const pathname = new URL(url).pathname; - const segments = pathname.split("/"); - const last = segments[segments.length - 1]; - return last && last.includes(".") ? last : "video.mp4"; - } catch { - return "video.mp4"; - } -} - /** * Handles content agent task callback from Trigger.dev. * Verifies the shared secret and dispatches based on callback status. @@ -80,37 +63,7 @@ export async function handleContentAgentCallback(request: Request): Promise r.status === "failed"); if (videos.length > 0) { - for (let i = 0; i < videos.length; i++) { - const v = videos[i]; - const videoBuffer = await downloadVideoBuffer(v.videoUrl!); - - if (videoBuffer) { - const filename = getFilenameFromUrl(v.videoUrl!); - const label = videos.length > 1 ? `**Video ${i + 1}**` : ""; - const caption = v.captionText ? `> ${v.captionText}` : ""; - const markdown = [label, caption].filter(Boolean).join("\n"); - - await thread.post({ - markdown: markdown || filename, - files: [ - { - data: videoBuffer, - filename, - mimeType: "video/mp4", - }, - ], - }); - } else { - // Fallback to URL link if download fails - const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; - const caption = v.captionText ? `\n> ${v.captionText}` : ""; - await thread.post(`${label}${v.videoUrl}${caption}`); - } - } - - if (failed.length > 0) { - await thread.post(`_${failed.length} run(s) failed._`); - } + await postVideoResults(thread, videos, failed.length); } else { await thread.post("Content generation finished but no videos were produced."); } diff --git a/lib/agents/content/postVideoResults.ts b/lib/agents/content/postVideoResults.ts new file mode 100644 index 00000000..a04250c2 --- /dev/null +++ b/lib/agents/content/postVideoResults.ts @@ -0,0 +1,64 @@ +import { downloadVideoBuffer } from "./downloadVideoBuffer"; +import { getFilenameFromUrl } from "./getFilenameFromUrl"; + +interface VideoResult { + runId: string; + status: string; + videoUrl?: string; + captionText?: string; +} + +interface Thread { + post: (message: string | { markdown: string; files: { data: Buffer; filename: string; mimeType: string }[] }) => Promise; +} + +/** + * Downloads completed videos in parallel and posts each to the thread. + * Falls back to posting the URL as text if a download fails. + * + * @param thread - The thread to post results to + * @param videos - Array of completed video results + * @param failedCount - Number of failed runs to report + */ +export async function postVideoResults( + thread: Thread, + videos: VideoResult[], + failedCount: number, +): Promise { + // Download all videos in parallel + const buffers = await Promise.all( + videos.map(v => downloadVideoBuffer(v.videoUrl!)), + ); + + // Post each video sequentially (Slack ordering matters) + for (let i = 0; i < videos.length; i++) { + const v = videos[i]; + const videoBuffer = buffers[i]; + + if (videoBuffer) { + const filename = getFilenameFromUrl(v.videoUrl!); + const label = videos.length > 1 ? `**Video ${i + 1}**` : ""; + const caption = v.captionText ? `> ${v.captionText}` : ""; + const markdown = [label, caption].filter(Boolean).join("\n"); + + await thread.post({ + markdown: markdown || filename, + files: [ + { + data: videoBuffer, + filename, + mimeType: "video/mp4", + }, + ], + }); + } else { + const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; + const caption = v.captionText ? `\n> ${v.captionText}` : ""; + await thread.post(`${label}${v.videoUrl}${caption}`); + } + } + + if (failedCount > 0) { + await thread.post(`_${failedCount} run(s) failed._`); + } +} From db93139946eba06ed6fd417a30dfc5bac04f8a86 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 1 Apr 2026 13:46:10 -0500 Subject: [PATCH 3/3] fix: formatting and Thread type compatibility Run prettier on new files. Change Thread.post return type from Promise to Promise to match ThreadImpl signature. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content/__tests__/getFilenameFromUrl.test.ts | 6 +++--- .../__tests__/handleContentAgentCallback.test.ts | 7 ++++++- lib/agents/content/postVideoResults.ts | 10 ++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/agents/content/__tests__/getFilenameFromUrl.test.ts b/lib/agents/content/__tests__/getFilenameFromUrl.test.ts index b60440ca..42fea32c 100644 --- a/lib/agents/content/__tests__/getFilenameFromUrl.test.ts +++ b/lib/agents/content/__tests__/getFilenameFromUrl.test.ts @@ -13,9 +13,9 @@ describe("getFilenameFromUrl", () => { }); it("handles URL-encoded characters", () => { - expect( - getFilenameFromUrl("https://cdn.example.com/my%20video%20file.mp4"), - ).toBe("my%20video%20file.mp4"); + expect(getFilenameFromUrl("https://cdn.example.com/my%20video%20file.mp4")).toBe( + "my%20video%20file.mp4", + ); }); it("falls back to video.mp4 when URL has no extension", () => { diff --git a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts index 592374f5..36fa4ea1 100644 --- a/lib/agents/content/__tests__/handleContentAgentCallback.test.ts +++ b/lib/agents/content/__tests__/handleContentAgentCallback.test.ts @@ -108,7 +108,12 @@ describe("handleContentAgentCallback", () => { threadId: "slack:C123:T456", status: "completed", results: [ - { runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/video.mp4", captionText: "Test" }, + { + runId: "run-1", + status: "completed", + videoUrl: "https://cdn.example.com/video.mp4", + captionText: "Test", + }, { runId: "run-2", status: "failed", error: "render error" }, ], }); diff --git a/lib/agents/content/postVideoResults.ts b/lib/agents/content/postVideoResults.ts index a04250c2..36bd9c13 100644 --- a/lib/agents/content/postVideoResults.ts +++ b/lib/agents/content/postVideoResults.ts @@ -9,7 +9,11 @@ interface VideoResult { } interface Thread { - post: (message: string | { markdown: string; files: { data: Buffer; filename: string; mimeType: string }[] }) => Promise; + post: ( + message: + | string + | { markdown: string; files: { data: Buffer; filename: string; mimeType: string }[] }, + ) => Promise; } /** @@ -26,9 +30,7 @@ export async function postVideoResults( failedCount: number, ): Promise { // Download all videos in parallel - const buffers = await Promise.all( - videos.map(v => downloadVideoBuffer(v.videoUrl!)), - ); + const buffers = await Promise.all(videos.map(v => downloadVideoBuffer(v.videoUrl!))); // Post each video sequentially (Slack ordering matters) for (let i = 0; i < videos.length; i++) {