feat(agent): add structured outputs and media archive support
This commit is contained in:
@@ -481,4 +481,52 @@ describe("AgentNode runtime", () => {
|
||||
|
||||
expect(mocks.runAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps execution progress fallback compatible with richer runtime execution step data", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(AgentNode, {
|
||||
id: "agent-4",
|
||||
selected: false,
|
||||
dragging: false,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
type: "agent",
|
||||
data: {
|
||||
canvasId: "canvas-1",
|
||||
templateId: "campaign-distributor",
|
||||
modelId: "openai/gpt-5.4-mini",
|
||||
_status: "executing",
|
||||
executionSteps: [
|
||||
{
|
||||
stepIndex: 0,
|
||||
stepTotal: 2,
|
||||
artifactType: "social-post",
|
||||
requiredSections: ["hook", "body", "cta"],
|
||||
qualityChecks: ["channel-fit"],
|
||||
},
|
||||
{
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
artifactType: "social-post",
|
||||
requiredSections: ["hook", "body", "cta"],
|
||||
qualityChecks: ["channel-fit"],
|
||||
},
|
||||
],
|
||||
} as Record<string, unknown>,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Executing planned outputs (2 total)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,46 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const handleCalls: Array<{ type: string; id?: string }> = [];
|
||||
const getAgentTemplateMock = vi.fn((id: string) => {
|
||||
if (id === "future-agent") {
|
||||
return {
|
||||
id: "future-agent",
|
||||
name: "Future Agent",
|
||||
description: "Generic definition-backed template metadata.",
|
||||
emoji: "rocket",
|
||||
color: "blue",
|
||||
vibe: "Builds reusable workflows.",
|
||||
tools: [],
|
||||
channels: ["Email", "LinkedIn"],
|
||||
expectedInputs: ["Text node"],
|
||||
expectedOutputs: ["Plan"],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (id === "campaign-distributor") {
|
||||
return {
|
||||
id: "campaign-distributor",
|
||||
name: "Campaign Distributor",
|
||||
description:
|
||||
"Develops and distributes LemonSpace campaign content across social media and messenger channels.",
|
||||
emoji: "lemon",
|
||||
color: "yellow",
|
||||
vibe: "Campaign-first",
|
||||
tools: [],
|
||||
channels: ["LinkedIn", "Instagram"],
|
||||
expectedInputs: ["Render"],
|
||||
expectedOutputs: ["Caption pack"],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
vi.mock("@/lib/agent-templates", () => ({
|
||||
getAgentTemplate: (id: string) => getAgentTemplateMock(id),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
@@ -71,6 +111,7 @@ describe("AgentNode", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
handleCalls.length = 0;
|
||||
getAgentTemplateMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -84,7 +125,7 @@ describe("AgentNode", () => {
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders campaign distributor metadata and source/target handles", async () => {
|
||||
it("renders definition-projected metadata and source/target handles without template-specific branching", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -102,7 +143,7 @@ describe("AgentNode", () => {
|
||||
isConnectable: true,
|
||||
type: "agent",
|
||||
data: {
|
||||
templateId: "campaign-distributor",
|
||||
templateId: "future-agent",
|
||||
_status: "idle",
|
||||
} as Record<string, unknown>,
|
||||
positionAbsoluteX: 0,
|
||||
@@ -111,7 +152,8 @@ describe("AgentNode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Campaign Distributor");
|
||||
expect(container.textContent).toContain("Future Agent");
|
||||
expect(container.textContent).toContain("Generic definition-backed template metadata.");
|
||||
expect(container.textContent).toContain("Briefing");
|
||||
expect(container.textContent).toContain("Constraints");
|
||||
expect(container.textContent).toContain("Template reference");
|
||||
@@ -119,7 +161,7 @@ describe("AgentNode", () => {
|
||||
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("falls back to the default template when templateId is missing", async () => {
|
||||
it("falls back to the default template when templateId is missing or unknown", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -137,6 +179,7 @@ describe("AgentNode", () => {
|
||||
isConnectable: true,
|
||||
type: "agent",
|
||||
data: {
|
||||
templateId: "unknown-template",
|
||||
_status: "done",
|
||||
} as Record<string, unknown>,
|
||||
positionAbsoluteX: 0,
|
||||
@@ -146,5 +189,7 @@ describe("AgentNode", () => {
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Campaign Distributor");
|
||||
expect(getAgentTemplateMock).toHaveBeenCalledWith("unknown-template");
|
||||
expect(getAgentTemplateMock).toHaveBeenCalledWith("campaign-distributor");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,13 @@ const translations: Record<string, string> = {
|
||||
"agentOutputNode.skeletonBadge": "Skeleton",
|
||||
"agentOutputNode.plannedOutputLabel": "Planned output",
|
||||
"agentOutputNode.channelLabel": "Channel",
|
||||
"agentOutputNode.typeLabel": "Type",
|
||||
"agentOutputNode.artifactTypeLabel": "Artifact type",
|
||||
"agentOutputNode.sectionsLabel": "Sections",
|
||||
"agentOutputNode.metadataLabel": "Metadata",
|
||||
"agentOutputNode.qualityChecksLabel": "Quality checks",
|
||||
"agentOutputNode.previewLabel": "Preview",
|
||||
"agentOutputNode.previewFallback": "No preview available",
|
||||
"agentOutputNode.emptyValue": "-",
|
||||
"agentOutputNode.bodyLabel": "Body",
|
||||
"agentOutputNode.plannedContent": "Planned content",
|
||||
};
|
||||
@@ -70,7 +76,7 @@ describe("AgentOutputNode", () => {
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders title, channel, output type, and body", async () => {
|
||||
it("renders structured output with artifact meta, sections, metadata, quality checks, and preview fallback", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -90,8 +96,18 @@ describe("AgentOutputNode", () => {
|
||||
data: {
|
||||
title: "Instagram Caption",
|
||||
channel: "instagram-feed",
|
||||
outputType: "caption",
|
||||
body: "A short punchy caption with hashtags",
|
||||
artifactType: "caption-pack",
|
||||
previewText: "A short punchy caption with hashtags",
|
||||
sections: [
|
||||
{ id: "hook", label: "Hook", content: "Launch day is here." },
|
||||
{ id: "body", label: "Body", content: "Built for modern teams." },
|
||||
],
|
||||
metadata: {
|
||||
objective: "Drive signups",
|
||||
tags: ["launch", "product"],
|
||||
},
|
||||
qualityChecks: ["channel-fit", "cta-present"],
|
||||
body: "Legacy body fallback",
|
||||
_status: "done",
|
||||
} as Record<string, unknown>,
|
||||
positionAbsoluteX: 0,
|
||||
@@ -102,10 +118,22 @@ describe("AgentOutputNode", () => {
|
||||
|
||||
expect(container.textContent).toContain("Instagram Caption");
|
||||
expect(container.textContent).toContain("instagram-feed");
|
||||
expect(container.textContent).toContain("caption");
|
||||
expect(container.textContent).toContain("caption-pack");
|
||||
expect(container.textContent).toContain("Sections");
|
||||
expect(container.textContent).toContain("Hook");
|
||||
expect(container.textContent).toContain("Launch day is here.");
|
||||
expect(container.textContent).toContain("Metadata");
|
||||
expect(container.textContent).toContain("objective");
|
||||
expect(container.textContent).toContain("Drive signups");
|
||||
expect(container.textContent).toContain("Quality checks");
|
||||
expect(container.textContent).toContain("channel-fit");
|
||||
expect(container.textContent).toContain("Preview");
|
||||
expect(container.textContent).toContain("A short punchy caption with hashtags");
|
||||
expect(container.querySelector('[data-testid="agent-output-meta-strip"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-text-body"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-sections"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-metadata"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-quality-checks"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-preview"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders parseable json body in a pretty-printed code block", async () => {
|
||||
@@ -128,7 +156,7 @@ describe("AgentOutputNode", () => {
|
||||
data: {
|
||||
title: "JSON output",
|
||||
channel: "api",
|
||||
outputType: "payload",
|
||||
artifactType: "payload",
|
||||
body: '{"post":"Hello","tags":["launch","news"]}',
|
||||
_status: "done",
|
||||
} as Record<string, unknown>,
|
||||
@@ -145,6 +173,40 @@ describe("AgentOutputNode", () => {
|
||||
expect(container.querySelector('[data-testid="agent-output-text-body"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to legacy text body when structured fields are absent", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(AgentOutputNode, {
|
||||
id: "agent-output-legacy",
|
||||
selected: false,
|
||||
dragging: false,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
type: "agent-output",
|
||||
data: {
|
||||
title: "Legacy output",
|
||||
channel: "linkedin",
|
||||
artifactType: "post",
|
||||
body: "Legacy body content",
|
||||
} as Record<string, unknown>,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="agent-output-text-body"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Legacy body content");
|
||||
expect(container.querySelector('[data-testid="agent-output-sections"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders input-only handle agent-output-in", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -165,7 +227,7 @@ describe("AgentOutputNode", () => {
|
||||
data: {
|
||||
title: "LinkedIn Post",
|
||||
channel: "linkedin",
|
||||
outputType: "post",
|
||||
artifactType: "post",
|
||||
body: "Body",
|
||||
} as Record<string, unknown>,
|
||||
positionAbsoluteX: 0,
|
||||
@@ -197,7 +259,7 @@ describe("AgentOutputNode", () => {
|
||||
data: {
|
||||
title: "Planned headline",
|
||||
channel: "linkedin",
|
||||
outputType: "post",
|
||||
artifactType: "post",
|
||||
isSkeleton: true,
|
||||
stepIndex: 1,
|
||||
stepTotal: 4,
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
80
tests/lib/agent-definitions.test.ts
Normal file
80
tests/lib/agent-definitions.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AGENT_DEFINITIONS,
|
||||
getAgentDefinition,
|
||||
} from "@/lib/agent-definitions";
|
||||
|
||||
describe("agent definitions", () => {
|
||||
it("registers exactly one runtime definition for now", () => {
|
||||
expect(AGENT_DEFINITIONS.map((definition) => definition.id)).toEqual([
|
||||
"campaign-distributor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns campaign distributor with runtime metadata and blueprint contract", () => {
|
||||
const definition = getAgentDefinition("campaign-distributor");
|
||||
|
||||
expect(definition?.metadata.name).toBe("Campaign Distributor");
|
||||
expect(definition?.metadata.color).toBe("yellow");
|
||||
expect(definition?.docs.markdownPath).toBe(
|
||||
"components/agents/campaign-distributor.md",
|
||||
);
|
||||
expect(definition?.acceptedSourceNodeTypes).toContain("text");
|
||||
expect(definition?.briefFieldOrder).toEqual([
|
||||
"briefing",
|
||||
"audience",
|
||||
"tone",
|
||||
"targetChannels",
|
||||
"hardConstraints",
|
||||
]);
|
||||
expect(definition?.channelCatalog).toContain("Instagram Feed");
|
||||
expect(definition?.operatorParameters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: "targetChannels", type: "multi-select" }),
|
||||
expect.objectContaining({ key: "variantsPerChannel", type: "select" }),
|
||||
expect.objectContaining({ key: "toneOverride", type: "select" }),
|
||||
]),
|
||||
);
|
||||
expect(definition?.analysisRules.length).toBeGreaterThan(0);
|
||||
expect(definition?.executionRules.length).toBeGreaterThan(0);
|
||||
expect(definition?.defaultOutputBlueprints).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
artifactType: "social-caption-pack",
|
||||
requiredSections: expect.arrayContaining(["Hook", "Caption"]),
|
||||
requiredMetadataKeys: expect.arrayContaining([
|
||||
"objective",
|
||||
"targetAudience",
|
||||
]),
|
||||
qualityChecks: expect.arrayContaining([
|
||||
"matches_channel_constraints",
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps shared runtime fields accessible without template-specific branching", () => {
|
||||
const definition = getAgentDefinition("campaign-distributor");
|
||||
if (!definition) {
|
||||
throw new Error("Missing definition");
|
||||
}
|
||||
|
||||
const commonProjection = {
|
||||
id: definition.id,
|
||||
markdownPath: definition.docs.markdownPath,
|
||||
sourceTypeCount: definition.acceptedSourceNodeTypes.length,
|
||||
blueprintCount: definition.defaultOutputBlueprints.length,
|
||||
};
|
||||
|
||||
expect(commonProjection).toEqual({
|
||||
id: "campaign-distributor",
|
||||
markdownPath: "components/agents/campaign-distributor.md",
|
||||
sourceTypeCount: definition.acceptedSourceNodeTypes.length,
|
||||
blueprintCount: definition.defaultOutputBlueprints.length,
|
||||
});
|
||||
expect(commonProjection.sourceTypeCount).toBeGreaterThan(0);
|
||||
expect(commonProjection.blueprintCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
88
tests/lib/agent-doc-segments.test.ts
Normal file
88
tests/lib/agent-doc-segments.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AGENT_PROMPT_SEGMENT_KEYS,
|
||||
compileAgentDocSegmentsFromMarkdown,
|
||||
type AgentPromptSegmentKey,
|
||||
} from "@/scripts/compile-agent-docs";
|
||||
import { AGENT_DOC_SEGMENTS } from "@/lib/generated/agent-doc-segments";
|
||||
|
||||
function markedSegment(key: AgentPromptSegmentKey, content: string): string {
|
||||
return [
|
||||
`<!-- AGENT_PROMPT_SEGMENT:${key}:start -->`,
|
||||
content,
|
||||
`<!-- AGENT_PROMPT_SEGMENT:${key}:end -->`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
describe("agent doc segment compiler", () => {
|
||||
it("extracts only explicitly marked sections in deterministic order", () => {
|
||||
const markdown = [
|
||||
"# Intro",
|
||||
"This prose should not be extracted.",
|
||||
markedSegment("role", "Role text"),
|
||||
"Unmarked detail should not leak.",
|
||||
markedSegment("style-rules", "Style text"),
|
||||
markedSegment("decision-framework", "Decision text"),
|
||||
markedSegment("channel-notes", "Channel text"),
|
||||
].join("\n\n");
|
||||
|
||||
const compiled = compileAgentDocSegmentsFromMarkdown(markdown, {
|
||||
sourcePath: "components/agents/test-agent.md",
|
||||
});
|
||||
|
||||
expect(Object.keys(compiled)).toEqual(AGENT_PROMPT_SEGMENT_KEYS);
|
||||
expect(compiled).toEqual({
|
||||
role: "Role text",
|
||||
"style-rules": "Style text",
|
||||
"decision-framework": "Decision text",
|
||||
"channel-notes": "Channel text",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include unmarked prose in extracted segments", () => {
|
||||
const markdown = [
|
||||
"Top prose that must stay out.",
|
||||
markedSegment("role", "Keep me"),
|
||||
"Random prose should not appear.",
|
||||
markedSegment("style-rules", "Keep style"),
|
||||
markedSegment("decision-framework", "Keep framework"),
|
||||
markedSegment("channel-notes", "Keep channels"),
|
||||
"Bottom prose that must stay out.",
|
||||
].join("\n\n");
|
||||
|
||||
const compiled = compileAgentDocSegmentsFromMarkdown(markdown, {
|
||||
sourcePath: "components/agents/test-agent.md",
|
||||
});
|
||||
|
||||
const joined = Object.values(compiled).join("\n");
|
||||
expect(joined).toContain("Keep me");
|
||||
expect(joined).not.toContain("Top prose");
|
||||
expect(joined).not.toContain("Bottom prose");
|
||||
expect(joined).not.toContain("Random prose");
|
||||
});
|
||||
|
||||
it("fails loudly when a required section marker is missing", () => {
|
||||
const markdown = [
|
||||
markedSegment("role", "Role text"),
|
||||
markedSegment("style-rules", "Style text"),
|
||||
markedSegment("decision-framework", "Decision text"),
|
||||
].join("\n\n");
|
||||
|
||||
expect(() =>
|
||||
compileAgentDocSegmentsFromMarkdown(markdown, {
|
||||
sourcePath: "components/agents/test-agent.md",
|
||||
}),
|
||||
).toThrowError(/channel-notes/);
|
||||
});
|
||||
|
||||
it("ships generated campaign distributor segments with all required keys", () => {
|
||||
const campaignDistributor = AGENT_DOC_SEGMENTS["campaign-distributor"];
|
||||
expect(campaignDistributor).toBeDefined();
|
||||
expect(Object.keys(campaignDistributor)).toEqual(AGENT_PROMPT_SEGMENT_KEYS);
|
||||
expect(campaignDistributor.role.length).toBeGreaterThan(0);
|
||||
expect(campaignDistributor["style-rules"].length).toBeGreaterThan(0);
|
||||
expect(campaignDistributor["decision-framework"].length).toBeGreaterThan(0);
|
||||
expect(campaignDistributor["channel-notes"].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
180
tests/lib/agent-prompting.test.ts
Normal file
180
tests/lib/agent-prompting.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAgentDefinition } from "@/lib/agent-definitions";
|
||||
import {
|
||||
buildAnalyzeMessages,
|
||||
buildExecuteMessages,
|
||||
summarizeIncomingContext,
|
||||
type PromptContextNode,
|
||||
} from "@/lib/agent-prompting";
|
||||
import { normalizeAgentExecutionPlan } from "@/lib/agent-run-contract";
|
||||
import { AGENT_DOC_SEGMENTS } from "@/lib/generated/agent-doc-segments";
|
||||
|
||||
describe("agent prompting helpers", () => {
|
||||
const definition = getAgentDefinition("campaign-distributor");
|
||||
|
||||
it("summarizes incoming context by node with whitelisted fields", () => {
|
||||
const nodes: PromptContextNode[] = [
|
||||
{
|
||||
nodeId: "node-2",
|
||||
type: "image",
|
||||
status: "done",
|
||||
data: {
|
||||
url: "https://cdn.example.com/render.png",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
ignored: "must not be included",
|
||||
},
|
||||
},
|
||||
{
|
||||
nodeId: "node-1",
|
||||
type: "text",
|
||||
status: "idle",
|
||||
data: {
|
||||
content: " Product launch headline ",
|
||||
secret: "do not include",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const summary = summarizeIncomingContext(nodes);
|
||||
|
||||
expect(summary).toContain("Incoming context nodes: 2");
|
||||
expect(summary).toContain("1. nodeId=node-1, type=text, status=idle");
|
||||
expect(summary).toContain("2. nodeId=node-2, type=image, status=done");
|
||||
expect(summary).toContain("content: Product launch headline");
|
||||
expect(summary).toContain("url: https://cdn.example.com/render.png");
|
||||
expect(summary).toContain("width: 1080");
|
||||
expect(summary).not.toContain("secret");
|
||||
expect(summary).not.toContain("ignored");
|
||||
});
|
||||
|
||||
it("buildAnalyzeMessages includes definition metadata, prompt segments, rules, and constraints", () => {
|
||||
if (!definition) {
|
||||
throw new Error("campaign-distributor definition missing");
|
||||
}
|
||||
|
||||
const messages = buildAnalyzeMessages({
|
||||
definition,
|
||||
locale: "en",
|
||||
briefConstraints: {
|
||||
briefing: "Create launch copy",
|
||||
audience: "Design leads",
|
||||
tone: "bold",
|
||||
targetChannels: ["instagram", "linkedin"],
|
||||
hardConstraints: ["No emojis"],
|
||||
},
|
||||
clarificationAnswers: {
|
||||
budget: "organic",
|
||||
},
|
||||
incomingContextSummary: "Incoming context nodes: 1",
|
||||
incomingContextCount: 1,
|
||||
promptSegments: AGENT_DOC_SEGMENTS["campaign-distributor"],
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]?.role).toBe("system");
|
||||
expect(messages[1]?.role).toBe("user");
|
||||
|
||||
const system = messages[0]?.content ?? "";
|
||||
const user = messages[1]?.content ?? "";
|
||||
|
||||
expect(system).toContain("Campaign Distributor");
|
||||
expect(system).toContain("role");
|
||||
expect(system).toContain("style-rules");
|
||||
expect(system).toContain("decision-framework");
|
||||
expect(system).toContain("channel-notes");
|
||||
expect(system).toContain("analysis rules");
|
||||
expect(system).toContain("default output blueprints");
|
||||
expect(user).toContain("Brief + constraints");
|
||||
expect(user).toContain("Current clarification answers");
|
||||
expect(user).toContain("Incoming context node count: 1");
|
||||
expect(user).toContain("Incoming context nodes: 1");
|
||||
});
|
||||
|
||||
it("buildExecuteMessages includes execution rules, plan summary, per-step requirements, and checks", () => {
|
||||
if (!definition) {
|
||||
throw new Error("campaign-distributor definition missing");
|
||||
}
|
||||
|
||||
const executionPlan = normalizeAgentExecutionPlan({
|
||||
summary: "Ship launch content",
|
||||
steps: [
|
||||
{
|
||||
id: " ig-feed ",
|
||||
title: " Instagram Feed Pack ",
|
||||
channel: " Instagram Feed ",
|
||||
outputType: "caption-pack",
|
||||
artifactType: "social-caption-pack",
|
||||
goal: "Drive comments",
|
||||
requiredSections: ["Hook", "Caption", "CTA"],
|
||||
qualityChecks: ["matches_channel_constraints"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const messages = buildExecuteMessages({
|
||||
definition,
|
||||
locale: "de",
|
||||
briefConstraints: {
|
||||
briefing: "Post launch update",
|
||||
audience: "Founders",
|
||||
tone: "energetic",
|
||||
targetChannels: ["instagram"],
|
||||
hardConstraints: ["No discounts"],
|
||||
},
|
||||
clarificationAnswers: {
|
||||
length: "short",
|
||||
},
|
||||
incomingContextSummary: "Incoming context nodes: 1",
|
||||
executionPlan,
|
||||
promptSegments: AGENT_DOC_SEGMENTS["campaign-distributor"],
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
const system = messages[0]?.content ?? "";
|
||||
const user = messages[1]?.content ?? "";
|
||||
|
||||
expect(system).toContain("execution rules");
|
||||
expect(system).toContain("channel-notes");
|
||||
expect(system).toContain("German (de-DE)");
|
||||
expect(user).toContain("Execution plan summary: Ship launch content");
|
||||
expect(user).toContain("artifactType: social-caption-pack");
|
||||
expect(user).toContain("requiredSections: Hook, Caption, CTA");
|
||||
expect(user).toContain("qualityChecks: matches_channel_constraints");
|
||||
});
|
||||
|
||||
it("prompt builders stay definition-driven without hardcoded template branches", () => {
|
||||
if (!definition) {
|
||||
throw new Error("campaign-distributor definition missing");
|
||||
}
|
||||
|
||||
const variantDefinition = {
|
||||
...definition,
|
||||
metadata: {
|
||||
...definition.metadata,
|
||||
name: "Custom Runtime Agent",
|
||||
description: "Definition override for test.",
|
||||
},
|
||||
};
|
||||
|
||||
const messages = buildAnalyzeMessages({
|
||||
definition: variantDefinition,
|
||||
locale: "en",
|
||||
briefConstraints: {
|
||||
briefing: "Test",
|
||||
audience: "Test",
|
||||
tone: "Test",
|
||||
targetChannels: ["x"],
|
||||
hardConstraints: [],
|
||||
},
|
||||
clarificationAnswers: {},
|
||||
incomingContextSummary: "Incoming context nodes: 0",
|
||||
incomingContextCount: 0,
|
||||
promptSegments: AGENT_DOC_SEGMENTS["campaign-distributor"],
|
||||
});
|
||||
|
||||
expect(messages[0]?.content ?? "").toContain("Custom Runtime Agent");
|
||||
expect(messages[0]?.content ?? "").toContain("Definition override for test.");
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,10 @@ describe("agent run contract helpers", () => {
|
||||
title: "Instagram captions",
|
||||
channel: "Instagram",
|
||||
outputType: "caption-pack",
|
||||
artifactType: "caption-pack",
|
||||
goal: "Deliver channel-ready output.",
|
||||
requiredSections: [],
|
||||
qualityChecks: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -149,6 +153,10 @@ describe("agent run contract helpers", () => {
|
||||
title: "Untitled",
|
||||
channel: "general",
|
||||
outputType: "text",
|
||||
artifactType: "text",
|
||||
goal: "Deliver channel-ready output.",
|
||||
requiredSections: [],
|
||||
qualityChecks: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -181,6 +189,51 @@ describe("agent run contract helpers", () => {
|
||||
|
||||
expect(normalized.steps.map((step) => step.id)).toEqual(["step", "step-2", "step-3"]);
|
||||
});
|
||||
|
||||
it("normalizes enriched execution-step fields with deterministic array handling", () => {
|
||||
const normalized = normalizeAgentExecutionPlan({
|
||||
summary: "ready",
|
||||
steps: [
|
||||
{
|
||||
id: "main",
|
||||
title: "Deliver",
|
||||
channel: "linkedin",
|
||||
outputType: "post",
|
||||
artifactType: " social-caption-pack ",
|
||||
goal: " Explain launch value ",
|
||||
requiredSections: ["Hook", "CTA", "Hook", " "],
|
||||
qualityChecks: ["fits_tone", "fits_tone", "references_context", ""],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalized.steps[0]).toEqual({
|
||||
id: "main",
|
||||
title: "Deliver",
|
||||
channel: "linkedin",
|
||||
outputType: "post",
|
||||
artifactType: "social-caption-pack",
|
||||
goal: "Explain launch value",
|
||||
requiredSections: ["Hook", "CTA"],
|
||||
qualityChecks: ["fits_tone", "references_context"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps compatibility by falling back artifactType to outputType", () => {
|
||||
const normalized = normalizeAgentExecutionPlan({
|
||||
summary: "ready",
|
||||
steps: [
|
||||
{
|
||||
id: "legacy",
|
||||
title: "Legacy step",
|
||||
channel: "email",
|
||||
outputType: "newsletter-copy",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalized.steps[0]?.artifactType).toBe("newsletter-copy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAgentBriefConstraints", () => {
|
||||
|
||||
148
tests/lib/agent-structured-output.test.ts
Normal file
148
tests/lib/agent-structured-output.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeAgentStructuredOutput } from "@/lib/agent-run-contract";
|
||||
|
||||
describe("normalizeAgentStructuredOutput", () => {
|
||||
it("preserves valid structured fields and compatibility body", () => {
|
||||
const normalized = normalizeAgentStructuredOutput(
|
||||
{
|
||||
title: " Launch Post ",
|
||||
channel: " linkedin ",
|
||||
artifactType: " social-post ",
|
||||
previewText: " Hook-first launch post. ",
|
||||
sections: [
|
||||
{
|
||||
id: " headline ",
|
||||
label: " Headline ",
|
||||
content: " Ship faster with LemonSpace. ",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
language: " en ",
|
||||
tags: [" launch ", "saas", ""],
|
||||
},
|
||||
qualityChecks: [" concise ", "concise", "channel-fit"],
|
||||
body: " Legacy flat content ",
|
||||
},
|
||||
{
|
||||
title: "Fallback Title",
|
||||
channel: "fallback-channel",
|
||||
artifactType: "fallback-artifact",
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
title: "Launch Post",
|
||||
channel: "linkedin",
|
||||
artifactType: "social-post",
|
||||
previewText: "Hook-first launch post.",
|
||||
sections: [
|
||||
{
|
||||
id: "headline",
|
||||
label: "Headline",
|
||||
content: "Ship faster with LemonSpace.",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
language: "en",
|
||||
tags: ["launch", "saas"],
|
||||
},
|
||||
qualityChecks: ["concise", "channel-fit"],
|
||||
body: "Legacy flat content",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes blank or malformed section entries", () => {
|
||||
const normalized = normalizeAgentStructuredOutput(
|
||||
{
|
||||
sections: [
|
||||
{
|
||||
id: "intro",
|
||||
label: "Intro",
|
||||
content: "Keep this section.",
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
label: "",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
id: "missing-content",
|
||||
label: "Missing Content",
|
||||
content: " ",
|
||||
},
|
||||
null,
|
||||
"bad-shape",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fallback Title",
|
||||
channel: "fallback-channel",
|
||||
artifactType: "fallback-artifact",
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized.sections).toEqual([
|
||||
{
|
||||
id: "intro",
|
||||
label: "Intro",
|
||||
content: "Keep this section.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives previewText deterministically from first valid section when missing", () => {
|
||||
const normalized = normalizeAgentStructuredOutput(
|
||||
{
|
||||
sections: [
|
||||
{
|
||||
id: "hook",
|
||||
label: "Hook",
|
||||
content: "First section content.",
|
||||
},
|
||||
{
|
||||
id: "cta",
|
||||
label: "CTA",
|
||||
content: "Second section content.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fallback Title",
|
||||
channel: "fallback-channel",
|
||||
artifactType: "fallback-artifact",
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized.previewText).toBe("First section content.");
|
||||
});
|
||||
|
||||
it("derives deterministic legacy body from sections when body is missing", () => {
|
||||
const normalized = normalizeAgentStructuredOutput(
|
||||
{
|
||||
previewText: "Preview should not override section flattening",
|
||||
sections: [
|
||||
{
|
||||
id: "hook",
|
||||
label: "Hook",
|
||||
content: "Lead with a bold claim.",
|
||||
},
|
||||
{
|
||||
id: "cta",
|
||||
label: "CTA",
|
||||
content: "Invite replies with a concrete question.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fallback Title",
|
||||
channel: "fallback-channel",
|
||||
artifactType: "fallback-artifact",
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized.body).toBe(
|
||||
"Hook:\nLead with a bold claim.\n\nCTA:\nInvite replies with a concrete question.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAgentDefinition } from "@/lib/agent-definitions";
|
||||
import { AGENT_TEMPLATES, getAgentTemplate } from "@/lib/agent-templates";
|
||||
|
||||
describe("agent templates", () => {
|
||||
@@ -9,14 +10,25 @@ describe("agent templates", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes normalized metadata needed by the canvas node", () => {
|
||||
it("projects runtime definition metadata for existing canvas callers", () => {
|
||||
const template = getAgentTemplate("campaign-distributor");
|
||||
const definition = getAgentDefinition("campaign-distributor");
|
||||
|
||||
expect(template?.name).toBe("Campaign Distributor");
|
||||
expect(template?.color).toBe("yellow");
|
||||
expect(template?.tools).toContain("WebFetch");
|
||||
expect(template?.channels).toContain("Instagram Feed");
|
||||
expect(template?.expectedInputs).toContain("Render-Node-Export");
|
||||
expect(template?.expectedOutputs).toContain("Caption-Pakete");
|
||||
expect(definition).toBeDefined();
|
||||
|
||||
expect(template?.name).toBe(definition?.metadata.name);
|
||||
expect(template?.description).toBe(definition?.metadata.description);
|
||||
expect(template?.emoji).toBe(definition?.metadata.emoji);
|
||||
expect(template?.color).toBe(definition?.metadata.color);
|
||||
expect(template?.vibe).toBe(definition?.metadata.vibe);
|
||||
expect(template?.tools).toEqual(definition?.uiReference.tools);
|
||||
expect(template?.channels).toEqual(definition?.channelCatalog);
|
||||
expect(template?.expectedInputs).toEqual(definition?.uiReference.expectedInputs);
|
||||
expect(template?.expectedOutputs).toEqual(definition?.uiReference.expectedOutputs);
|
||||
expect(template?.notes).toEqual(definition?.uiReference.notes);
|
||||
});
|
||||
|
||||
it("keeps unknown template lookup behavior unchanged", () => {
|
||||
expect(getAgentTemplate("unknown-template")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearDashboardSnapshotCache,
|
||||
emitDashboardSnapshotCacheInvalidationSignal,
|
||||
getDashboardSnapshotCacheInvalidationSignalKey,
|
||||
invalidateDashboardSnapshotForLastSignedInUser,
|
||||
readDashboardSnapshotCache,
|
||||
writeDashboardSnapshotCache,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
|
||||
const USER_ID = "user-cache-test";
|
||||
const LAST_DASHBOARD_USER_KEY = "ls-last-dashboard-user";
|
||||
const INVALIDATION_SIGNAL_KEY = "lemonspace.dashboard:snapshot:invalidate:v1";
|
||||
const INVALIDATION_SIGNAL_KEY = getDashboardSnapshotCacheInvalidationSignalKey();
|
||||
|
||||
describe("dashboard snapshot cache", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
Reference in New Issue
Block a user