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

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