feat(agent): localize generated agent workflow

This commit is contained in:
2026-04-10 13:56:11 +02:00
parent 66646bd62f
commit ddb2412349
10 changed files with 950 additions and 89 deletions

View File

@@ -89,6 +89,56 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
}));
const translations: Record<string, string> = {
"agentNode.templates.campaignDistributor.name": "Campaign Distributor",
"agentNode.templates.campaignDistributor.description":
"Develops and distributes LemonSpace campaign content across social media and messenger channels.",
"agentNode.modelLabel": "Model",
"agentNode.modelCreditMeta": "{model} - {credits} Cr",
"agentNode.briefingLabel": "Briefing",
"agentNode.briefingPlaceholder": "Describe the core task and desired output.",
"agentNode.constraintsLabel": "Constraints",
"agentNode.audienceLabel": "Audience",
"agentNode.toneLabel": "Tone",
"agentNode.targetChannelsLabel": "Target channels",
"agentNode.targetChannelsPlaceholder": "LinkedIn, Instagram Feed",
"agentNode.hardConstraintsLabel": "Hard constraints",
"agentNode.hardConstraintsPlaceholder": "No emojis\nMax 120 words",
"agentNode.runAgentButton": "Run agent",
"agentNode.clarificationsLabel": "Clarifications",
"agentNode.submitClarificationButton": "Submit clarification",
"agentNode.templateReferenceLabel": "Template reference",
"agentNode.templateReferenceChannelsLabel": "Channels",
"agentNode.templateReferenceInputsLabel": "Inputs",
"agentNode.templateReferenceOutputsLabel": "Outputs",
"agentNode.executingStepFallback": "Executing step {current}/{total}",
"agentNode.executingPlannedTotalFallback": "Executing planned outputs ({total} total)",
"agentNode.executingPlannedFallback": "Executing planned outputs",
"agentNode.offlineTitle": "Offline currently not supported",
"agentNode.offlineDescription": "Agent run requires an active connection.",
"agentNode.clarificationPrompts.briefing":
"What should the agent produce? Provide the brief in one or two sentences.",
"agentNode.clarificationPrompts.targetChannels":
"Which channels should this run target? List at least one channel.",
"agentNode.clarificationPrompts.incomingContext":
"No context was provided. What source context should the agent use?",
};
vi.mock("next-intl", () => ({
useLocale: () => "de",
useTranslations: (namespace?: string) =>
(key: string, values?: Record<string, unknown>) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
let text = translations[fullKey] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, String(value));
}
}
return text;
},
}));
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Left: "left", Right: "right" },
@@ -143,8 +193,15 @@ describe("AgentNode runtime", () => {
canvasId: "canvas-1",
templateId: "campaign-distributor",
modelId: "openai/gpt-5.4-mini",
briefConstraints: {
briefing: "Draft channel-ready campaign copy",
audience: "SaaS founders",
tone: "Confident and practical",
targetChannels: ["LinkedIn", "Instagram Feed"],
hardConstraints: ["No emojis", "Max 120 words"],
},
clarificationQuestions: [
{ id: "audience", prompt: "Target audience?", required: true },
{ id: "briefing", prompt: "RAW_BRIEFING_PROMPT", required: true },
],
clarificationAnswers: {},
} as Record<string, unknown>,
@@ -167,9 +224,27 @@ describe("AgentNode runtime", () => {
expect(container.textContent).toContain("GPT-5.4 Mini");
expect(container.textContent).toContain("15 Cr");
expect(container.textContent).toContain("Channels");
expect(container.textContent).toContain("Expected Inputs");
expect(container.textContent).toContain("Expected Outputs");
expect(container.textContent).toContain("Briefing");
expect(container.textContent).toContain("Constraints");
expect(container.textContent).toContain("Template reference");
const briefingTextarea = container.querySelector('textarea[name="agent-briefing"]');
if (!(briefingTextarea instanceof HTMLTextAreaElement)) {
throw new Error("Briefing textarea not found");
}
expect(briefingTextarea.value).toBe("Draft channel-ready campaign copy");
const targetChannelsInput = container.querySelector('input[name="agent-target-channels"]');
if (!(targetChannelsInput instanceof HTMLInputElement)) {
throw new Error("Target channels input not found");
}
expect(targetChannelsInput.value).toBe("LinkedIn, Instagram Feed");
const hardConstraintsInput = container.querySelector('textarea[name="agent-hard-constraints"]');
if (!(hardConstraintsInput instanceof HTMLTextAreaElement)) {
throw new Error("Hard constraints textarea not found");
}
expect(hardConstraintsInput.value).toBe("No emojis\nMax 120 words");
await act(async () => {
modelSelect.value = "openai/gpt-5.4";
@@ -183,7 +258,71 @@ describe("AgentNode runtime", () => {
}),
);
const clarificationInput = container.querySelector('input[name="clarification-audience"]');
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(briefingTextarea, "Adapt this launch to each channel");
briefingTextarea.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
briefConstraints: expect.objectContaining({
briefing: "Adapt this launch to each channel",
}),
}),
}),
);
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
valueSetter?.call(targetChannelsInput, "LinkedIn, X, TikTok");
targetChannelsInput.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
briefConstraints: expect.objectContaining({
targetChannels: ["LinkedIn", "X", "TikTok"],
}),
}),
}),
);
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(hardConstraintsInput, "No emojis\nMax 80 words, include CTA");
hardConstraintsInput.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
briefConstraints: expect.objectContaining({
hardConstraints: ["No emojis", "Max 80 words", "include CTA"],
}),
}),
}),
);
expect(container.textContent).toContain(
"What should the agent produce? Provide the brief in one or two sentences.",
);
const clarificationInput = container.querySelector('input[name="clarification-briefing"]');
if (!(clarificationInput instanceof HTMLInputElement)) {
throw new Error("Clarification input not found");
}
@@ -201,7 +340,7 @@ describe("AgentNode runtime", () => {
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
clarificationAnswers: expect.objectContaining({ audience: "SaaS founders" }),
clarificationAnswers: expect.objectContaining({ briefing: "SaaS founders" }),
}),
}),
);
@@ -221,6 +360,7 @@ describe("AgentNode runtime", () => {
canvasId: "canvas-1",
nodeId: "agent-1",
modelId: "openai/gpt-5.4",
locale: "de",
});
const submitButton = Array.from(container.querySelectorAll("button")).find((element) =>
@@ -237,7 +377,8 @@ describe("AgentNode runtime", () => {
expect(mocks.resumeAgent).toHaveBeenCalledWith({
canvasId: "canvas-1",
nodeId: "agent-1",
clarificationAnswers: { audience: "SaaS founders" },
clarificationAnswers: { briefing: "SaaS founders" },
locale: "de",
});
});

View File

@@ -22,6 +22,45 @@ vi.mock("@xyflow/react", () => ({
Position: { Left: "left", Right: "right" },
}));
const translations: Record<string, string> = {
"agentNode.templates.campaignDistributor.name": "Campaign Distributor",
"agentNode.templates.campaignDistributor.description":
"Develops and distributes LemonSpace campaign content across social media and messenger channels.",
"agentNode.modelLabel": "Model",
"agentNode.modelCreditMeta": "{model} - {credits} Cr",
"agentNode.briefingLabel": "Briefing",
"agentNode.briefingPlaceholder": "Describe the core task and desired output.",
"agentNode.constraintsLabel": "Constraints",
"agentNode.audienceLabel": "Audience",
"agentNode.toneLabel": "Tone",
"agentNode.targetChannelsLabel": "Target channels",
"agentNode.targetChannelsPlaceholder": "LinkedIn, Instagram Feed",
"agentNode.hardConstraintsLabel": "Hard constraints",
"agentNode.hardConstraintsPlaceholder": "No emojis\nMax 120 words",
"agentNode.runAgentButton": "Run agent",
"agentNode.clarificationsLabel": "Clarifications",
"agentNode.submitClarificationButton": "Submit clarification",
"agentNode.templateReferenceLabel": "Template reference",
"agentNode.templateReferenceChannelsLabel": "Channels",
"agentNode.templateReferenceInputsLabel": "Inputs",
"agentNode.templateReferenceOutputsLabel": "Outputs",
};
vi.mock("next-intl", () => ({
useLocale: () => "de",
useTranslations: (namespace?: string) =>
(key: string, values?: Record<string, unknown>) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
let text = translations[fullKey] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, String(value));
}
}
return text;
},
}));
import AgentNode from "@/components/canvas/nodes/agent-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -73,8 +112,9 @@ describe("AgentNode", () => {
});
expect(container.textContent).toContain("Campaign Distributor");
expect(container.textContent).toContain("Instagram Feed");
expect(container.textContent).toContain("Caption-Pakete");
expect(container.textContent).toContain("Briefing");
expect(container.textContent).toContain("Constraints");
expect(container.textContent).toContain("Template reference");
expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1);
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1);
});

View File

@@ -22,6 +22,31 @@ vi.mock("@xyflow/react", () => ({
Position: { Left: "left", Right: "right" },
}));
const translations: Record<string, string> = {
"agentOutputNode.defaultTitle": "Agent output",
"agentOutputNode.plannedOutputDefaultTitle": "Planned output",
"agentOutputNode.skeletonBadge": "Skeleton",
"agentOutputNode.plannedOutputLabel": "Planned output",
"agentOutputNode.channelLabel": "Channel",
"agentOutputNode.typeLabel": "Type",
"agentOutputNode.bodyLabel": "Body",
"agentOutputNode.plannedContent": "Planned content",
};
vi.mock("next-intl", () => ({
useTranslations: (namespace?: string) =>
(key: string, values?: Record<string, unknown>) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
let text = translations[fullKey] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, String(value));
}
}
return text;
},
}));
import AgentOutputNode from "@/components/canvas/nodes/agent-output-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -79,6 +104,45 @@ describe("AgentOutputNode", () => {
expect(container.textContent).toContain("instagram-feed");
expect(container.textContent).toContain("caption");
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();
});
it("renders parseable json body in a pretty-printed code block", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentOutputNode, {
id: "agent-output-4",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent-output",
data: {
title: "JSON output",
channel: "api",
outputType: "payload",
body: '{"post":"Hello","tags":["launch","news"]}',
_status: "done",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
const jsonBody = container.querySelector('[data-testid="agent-output-json-body"]');
expect(jsonBody).not.toBeNull();
expect(jsonBody?.textContent).toContain('"post": "Hello"');
expect(jsonBody?.textContent).toContain('"tags": [');
expect(container.querySelector('[data-testid="agent-output-text-body"]')).toBeNull();
});
it("renders input-only handle agent-output-in", async () => {

View File

@@ -2,10 +2,14 @@ import { describe, expect, it } from "vitest";
import {
areClarificationAnswersComplete,
buildPreflightClarificationQuestions,
normalizeAgentLocale,
normalizeAgentExecutionPlan,
normalizeAgentBriefConstraints,
normalizeAgentOutputDraft,
type AgentClarificationAnswerMap,
type AgentClarificationQuestion,
type AgentBriefConstraints,
type AgentExecutionPlan,
} from "@/lib/agent-run-contract";
@@ -178,4 +182,108 @@ describe("agent run contract helpers", () => {
expect(normalized.steps.map((step) => step.id)).toEqual(["step", "step-2", "step-3"]);
});
});
describe("normalizeAgentBriefConstraints", () => {
it("trims fields and normalizes target channels", () => {
const normalized = normalizeAgentBriefConstraints({
briefing: " Build a launch sequence ",
audience: " SaaS founders ",
tone: " concise ",
targetChannels: [" Email ", "", "LinkedIn", "email", " "],
hardConstraints: [
" Do not mention pricing ",
"",
"Keep under 120 words",
"Keep under 120 words",
],
});
expect(normalized).toEqual<AgentBriefConstraints>({
briefing: "Build a launch sequence",
audience: "SaaS founders",
tone: "concise",
targetChannels: ["email", "linkedin"],
hardConstraints: ["Do not mention pricing", "Keep under 120 words"],
});
});
it("falls back to safe empty values for invalid payloads", () => {
const normalized = normalizeAgentBriefConstraints({
briefing: null,
audience: undefined,
tone: 3,
targetChannels: [" ", null, 1],
hardConstraints: "must be array",
});
expect(normalized).toEqual<AgentBriefConstraints>({
briefing: "",
audience: "",
tone: "",
targetChannels: [],
hardConstraints: [],
});
});
});
describe("buildPreflightClarificationQuestions", () => {
it("builds deterministic required questions for missing preflight requirements", () => {
const questions = buildPreflightClarificationQuestions({
briefConstraints: {
briefing: "",
audience: "Founders",
tone: "confident",
targetChannels: [],
hardConstraints: [],
},
incomingContextCount: 0,
});
expect(questions).toEqual<AgentClarificationQuestion[]>([
{
id: "briefing",
prompt: "What should the agent produce? Provide the brief in one or two sentences.",
required: true,
},
{
id: "target-channels",
prompt: "Which channels should this run target? List at least one channel.",
required: true,
},
{
id: "incoming-context",
prompt: "No context was provided. What source context should the agent use?",
required: true,
},
]);
});
it("returns an empty list when all preflight requirements are satisfied", () => {
const questions = buildPreflightClarificationQuestions({
briefConstraints: {
briefing: "Create 3 posts",
audience: "Marketers",
tone: "friendly",
targetChannels: ["x"],
hardConstraints: ["No emojis"],
},
incomingContextCount: 2,
});
expect(questions).toEqual([]);
});
});
describe("normalizeAgentLocale", () => {
it("returns supported locale values", () => {
expect(normalizeAgentLocale("de")).toBe("de");
expect(normalizeAgentLocale("en")).toBe("en");
});
it("falls back to de for unsupported values", () => {
expect(normalizeAgentLocale("fr")).toBe("de");
expect(normalizeAgentLocale(undefined)).toBe("de");
expect(normalizeAgentLocale(null)).toBe("de");
});
});
});