feat(agent): add structured outputs and media archive support
This commit is contained in:
105
tests/convex/agent-orchestration-contract.test.ts
Normal file
105
tests/convex/agent-orchestration-contract.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testables } from "@/convex/agents";
|
||||
|
||||
describe("agent orchestration contract helpers", () => {
|
||||
it("builds skeleton output data with rich execution-plan metadata", () => {
|
||||
const data = __testables.buildSkeletonOutputData({
|
||||
step: {
|
||||
id: "step-linkedin",
|
||||
title: "LinkedIn Launch",
|
||||
channel: "linkedin",
|
||||
outputType: "post",
|
||||
artifactType: "social-post",
|
||||
goal: "Ship launch copy",
|
||||
requiredSections: ["hook", "body", "cta"],
|
||||
qualityChecks: ["channel-fit", "clear-cta"],
|
||||
},
|
||||
stepIndex: 1,
|
||||
stepTotal: 3,
|
||||
definitionVersion: 4,
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
isSkeleton: true,
|
||||
stepId: "step-linkedin",
|
||||
stepIndex: 1,
|
||||
stepTotal: 3,
|
||||
title: "LinkedIn Launch",
|
||||
channel: "linkedin",
|
||||
outputType: "post",
|
||||
artifactType: "social-post",
|
||||
requiredSections: ["hook", "body", "cta"],
|
||||
qualityChecks: ["channel-fit", "clear-cta"],
|
||||
definitionVersion: 4,
|
||||
});
|
||||
expect(data.previewText).toBe("Draft pending for LinkedIn Launch.");
|
||||
});
|
||||
|
||||
it("builds completed output data and derives deterministic legacy body fallback", () => {
|
||||
const data = __testables.buildCompletedOutputData({
|
||||
step: {
|
||||
id: "step-linkedin",
|
||||
title: "LinkedIn Launch",
|
||||
channel: "linkedin",
|
||||
outputType: "post",
|
||||
artifactType: "social-post",
|
||||
goal: "Ship launch copy",
|
||||
requiredSections: ["hook", "body", "cta"],
|
||||
qualityChecks: ["channel-fit", "clear-cta"],
|
||||
},
|
||||
stepIndex: 0,
|
||||
stepTotal: 1,
|
||||
output: {
|
||||
title: "LinkedIn Launch",
|
||||
channel: "linkedin",
|
||||
artifactType: "social-post",
|
||||
previewText: "",
|
||||
sections: [
|
||||
{ id: "hook", label: "Hook", content: "Lead with proof." },
|
||||
{ id: "cta", label: "CTA", content: "Invite comments." },
|
||||
],
|
||||
metadata: { audience: "SaaS founders" },
|
||||
qualityChecks: [],
|
||||
body: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(data.isSkeleton).toBe(false);
|
||||
expect(data.body).toBe("Hook:\nLead with proof.\n\nCTA:\nInvite comments.");
|
||||
expect(data.previewText).toBe("Lead with proof.");
|
||||
expect(data.qualityChecks).toEqual(["channel-fit", "clear-cta"]);
|
||||
});
|
||||
|
||||
it("requires rich execution-step fields in analyze schema", () => {
|
||||
const required = __testables.getAnalyzeExecutionStepRequiredFields();
|
||||
expect(required).toEqual(
|
||||
expect.arrayContaining([
|
||||
"id",
|
||||
"title",
|
||||
"channel",
|
||||
"outputType",
|
||||
"artifactType",
|
||||
"goal",
|
||||
"requiredSections",
|
||||
"qualityChecks",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves persisted summaries consistently across analyze and execute", () => {
|
||||
const promptSummary = __testables.resolveExecutionPlanSummary({
|
||||
executionPlanSummary: "",
|
||||
analysisSummary: "Audience and channels clarified.",
|
||||
});
|
||||
expect(promptSummary).toBe("Audience and channels clarified.");
|
||||
|
||||
const finalSummary = __testables.resolveFinalExecutionSummary({
|
||||
executionSummary: "",
|
||||
modelSummary: "Delivered 3 channel drafts.",
|
||||
executionPlanSummary: "Plan for 3 outputs.",
|
||||
analysisSummary: "Audience and channels clarified.",
|
||||
});
|
||||
expect(finalSummary).toBe("Delivered 3 channel drafts.");
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
listMediaArchiveItems,
|
||||
upsertMediaItemByOwnerAndDedupe,
|
||||
} from "@/convex/media";
|
||||
import { listMediaLibrary } from "@/convex/dashboard";
|
||||
import { verifyOwnedStorageIds } from "@/convex/storage";
|
||||
import { registerUploadedImageMedia } from "@/convex/storage";
|
||||
import { buildStoredMediaDedupeKey } from "@/lib/media-archive";
|
||||
import { requireAuth } from "@/convex/helpers";
|
||||
import { optionalAuth, requireAuth } from "@/convex/helpers";
|
||||
|
||||
type MockMediaItem = {
|
||||
_id: Id<"mediaItems">;
|
||||
@@ -89,6 +90,98 @@ function createMockDb(initialRows: MockMediaItem[] = []) {
|
||||
};
|
||||
}
|
||||
|
||||
type MockDashboardMediaItem = {
|
||||
_id: Id<"mediaItems">;
|
||||
_creationTime: number;
|
||||
ownerId: string;
|
||||
dedupeKey: string;
|
||||
kind: "image" | "video" | "asset";
|
||||
source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video";
|
||||
storageId?: Id<"_storage">;
|
||||
previewStorageId?: Id<"_storage">;
|
||||
originalUrl?: string;
|
||||
previewUrl?: string;
|
||||
sourceUrl?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function createListMediaLibraryCtx(mediaItems: MockDashboardMediaItem[]) {
|
||||
return {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "mediaItems") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
index: "by_owner_updated" | "by_owner_kind_updated",
|
||||
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
const clauses: Array<{ field: string; value: unknown }> = [];
|
||||
const queryBuilder = {
|
||||
eq(field: string, value: unknown) {
|
||||
clauses.push({ field, value });
|
||||
return this;
|
||||
},
|
||||
};
|
||||
apply(queryBuilder);
|
||||
|
||||
let filtered = [...mediaItems];
|
||||
const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value;
|
||||
if (ownerId) {
|
||||
filtered = filtered.filter((item) => item.ownerId === ownerId);
|
||||
}
|
||||
|
||||
if (index === "by_owner_kind_updated") {
|
||||
const kind = clauses.find((clause) => clause.field === "kind")?.value;
|
||||
filtered = filtered.filter((item) => item.kind === kind);
|
||||
}
|
||||
|
||||
const sorted = filtered.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
return {
|
||||
order: vi.fn((direction: "desc") => {
|
||||
expect(direction).toBe("desc");
|
||||
return {
|
||||
collect: vi.fn(async () => sorted),
|
||||
take: vi.fn(async (count: number) => sorted.slice(0, count)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "canvases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
collect: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "nodes") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected table query: ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("media archive", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -475,4 +568,129 @@ describe("media archive", () => {
|
||||
firstSourceNodeId: nodeId,
|
||||
});
|
||||
});
|
||||
|
||||
it("listMediaLibrary returns paginated object with default page size 8", async () => {
|
||||
vi.mocked(optionalAuth).mockResolvedValue({ userId: "user_1" } as never);
|
||||
|
||||
const mediaItems: MockDashboardMediaItem[] = Array.from({ length: 11 }, (_, index) => ({
|
||||
_id: `media_${index + 1}` as Id<"mediaItems">,
|
||||
_creationTime: index + 1,
|
||||
ownerId: "user_1",
|
||||
dedupeKey: `storage:s${index + 1}`,
|
||||
kind: index % 2 === 0 ? "image" : "video",
|
||||
source: index % 2 === 0 ? "upload" : "ai-video",
|
||||
storageId: `storage_${index + 1}` as Id<"_storage">,
|
||||
updatedAt: 1000 - index,
|
||||
}));
|
||||
|
||||
const result = await (listMediaLibrary as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
|
||||
})._handler(createListMediaLibraryCtx(mediaItems) as never, { page: 1 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 8,
|
||||
totalPages: 2,
|
||||
totalCount: 11,
|
||||
});
|
||||
expect((result as { items: unknown[] }).items).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("listMediaLibrary paginates with page and pageSize args", async () => {
|
||||
vi.mocked(optionalAuth).mockResolvedValue({ userId: "user_1" } as never);
|
||||
|
||||
const mediaItems: MockDashboardMediaItem[] = Array.from({ length: 12 }, (_, index) => ({
|
||||
_id: `media_${index + 1}` as Id<"mediaItems">,
|
||||
_creationTime: index + 1,
|
||||
ownerId: "user_1",
|
||||
dedupeKey: `storage:s${index + 1}`,
|
||||
kind: index % 2 === 0 ? "image" : "video",
|
||||
source: index % 2 === 0 ? "upload" : "ai-video",
|
||||
storageId: `storage_${index + 1}` as Id<"_storage">,
|
||||
updatedAt: 2000 - index,
|
||||
}));
|
||||
|
||||
const result = await (listMediaLibrary as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
|
||||
})._handler(createListMediaLibraryCtx(mediaItems) as never, { page: 2, pageSize: 5 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
page: 2,
|
||||
pageSize: 5,
|
||||
totalPages: 3,
|
||||
totalCount: 12,
|
||||
});
|
||||
expect((result as { items: unknown[] }).items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("listMediaLibrary applies kindFilter with paginated response", async () => {
|
||||
vi.mocked(optionalAuth).mockResolvedValue({ userId: "user_1" } as never);
|
||||
|
||||
const mediaItems: MockDashboardMediaItem[] = [
|
||||
{
|
||||
_id: "media_1" as Id<"mediaItems">,
|
||||
_creationTime: 1,
|
||||
ownerId: "user_1",
|
||||
dedupeKey: "storage:image_1",
|
||||
kind: "image",
|
||||
source: "upload",
|
||||
storageId: "storage_image_1" as Id<"_storage">,
|
||||
updatedAt: 100,
|
||||
},
|
||||
{
|
||||
_id: "media_2" as Id<"mediaItems">,
|
||||
_creationTime: 2,
|
||||
ownerId: "user_1",
|
||||
dedupeKey: "storage:video_1",
|
||||
kind: "video",
|
||||
source: "ai-video",
|
||||
storageId: "storage_video_1" as Id<"_storage">,
|
||||
updatedAt: 99,
|
||||
},
|
||||
{
|
||||
_id: "media_3" as Id<"mediaItems">,
|
||||
_creationTime: 3,
|
||||
ownerId: "user_1",
|
||||
dedupeKey: "storage:video_2",
|
||||
kind: "video",
|
||||
source: "ai-video",
|
||||
storageId: "storage_video_2" as Id<"_storage">,
|
||||
updatedAt: 98,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await (listMediaLibrary as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
|
||||
})._handler(createListMediaLibraryCtx(mediaItems) as never, {
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
kindFilter: "video",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
totalPages: 2,
|
||||
totalCount: 2,
|
||||
});
|
||||
expect((result as { items: Array<{ kind: string }> }).items).toEqual([
|
||||
expect.objectContaining({ kind: "video" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("listMediaLibrary returns paginated empty shape when unauthenticated", async () => {
|
||||
vi.mocked(optionalAuth).mockResolvedValue(null);
|
||||
|
||||
const result = await (listMediaLibrary as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
|
||||
})._handler(createListMediaLibraryCtx([]) as never, { page: 2, pageSize: 5 });
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [],
|
||||
page: 2,
|
||||
pageSize: 5,
|
||||
totalPages: 0,
|
||||
totalCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user