feat(agent): add structured outputs and media archive support

This commit is contained in:
2026-04-10 19:01:04 +02:00
parent a1df097f9c
commit 9732022461
34 changed files with 3276 additions and 482 deletions

View File

@@ -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)");
});
});

View File

@@ -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");
});
});

View File

@@ -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,

View 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.");
});
});

View File

@@ -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,
});
});
});

View 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);
});
});

View 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);
});
});

View 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.");
});
});

View File

@@ -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", () => {

View 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.",
);
});
});

View File

@@ -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();
});
});

View File

@@ -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(() => {