feat(agent): add execution-plan skeleton workflow

This commit is contained in:
2026-04-09 21:11:21 +02:00
parent 29c93eeb35
commit 26d008705f
8 changed files with 708 additions and 98 deletions

View File

@@ -37,6 +37,11 @@ type AgentNodeData = {
templateId?: string;
canvasId?: string;
modelId?: string;
executionSteps?: Array<{ stepIndex?: number; stepTotal?: number }>;
executionStepIndex?: number;
executionStepTotal?: number;
_executionStepIndex?: number;
_executionStepTotal?: number;
clarificationQuestions?: AgentClarificationQuestion[];
clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>;
_status?: string;
@@ -197,6 +202,51 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const resolvedModelId = selectedModel?.id ?? DEFAULT_AGENT_MODEL_ID;
const creditCost = selectedModel?.creditCost ?? 0;
const clarificationQuestions = nodeData.clarificationQuestions ?? [];
const isExecutionActive = nodeData._status === "analyzing" || nodeData._status === "executing";
const executionProgressLine = useMemo(() => {
if (nodeData._status !== "executing") {
return null;
}
const statusMessage = typeof nodeData._statusMessage === "string" ? nodeData._statusMessage.trim() : "";
if (statusMessage.length > 0) {
return statusMessage;
}
const totalFromSteps = Array.isArray(nodeData.executionSteps) ? nodeData.executionSteps.length : 0;
const stepIndexCandidate =
typeof nodeData.executionStepIndex === "number"
? nodeData.executionStepIndex
: nodeData._executionStepIndex;
const stepTotalCandidate =
typeof nodeData.executionStepTotal === "number"
? nodeData.executionStepTotal
: nodeData._executionStepTotal;
const hasExecutionNumbers =
typeof stepIndexCandidate === "number" &&
Number.isFinite(stepIndexCandidate) &&
typeof stepTotalCandidate === "number" &&
Number.isFinite(stepTotalCandidate) &&
stepTotalCandidate > 0;
if (hasExecutionNumbers) {
return `Executing step ${Math.max(0, Math.floor(stepIndexCandidate)) + 1}/${Math.floor(stepTotalCandidate)}`;
}
if (totalFromSteps > 0) {
return `Executing planned outputs (${totalFromSteps} total)`;
}
return "Executing planned outputs";
}, [
nodeData._executionStepIndex,
nodeData._executionStepTotal,
nodeData._status,
nodeData._statusMessage,
nodeData.executionStepIndex,
nodeData.executionStepTotal,
nodeData.executionSteps,
]);
const persistNodeData = useCallback(
(patch: Partial<AgentNodeData>) => {
@@ -239,6 +289,10 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
);
const handleRunAgent = useCallback(async () => {
if (isExecutionActive) {
return;
}
if (status.isOffline) {
toast.warning(
"Offline aktuell nicht unterstuetzt",
@@ -257,7 +311,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
nodeId: id as Id<"nodes">,
modelId: resolvedModelId,
});
}, [nodeData.canvasId, id, resolvedModelId, runAgent, status.isOffline]);
}, [isExecutionActive, nodeData.canvasId, id, resolvedModelId, runAgent, status.isOffline]);
const handleSubmitClarification = useCallback(async () => {
if (status.isOffline) {
@@ -298,6 +352,11 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
id="agent-in"
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
/>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
/>
<div className="flex h-full flex-col gap-3 p-3">
<header className="space-y-1">
@@ -334,10 +393,15 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
<button
type="button"
onClick={() => void handleRunAgent()}
className="nodrag w-full rounded-md bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700"
disabled={isExecutionActive}
aria-busy={isExecutionActive}
className="nodrag w-full rounded-md bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60"
>
Run agent
</button>
{executionProgressLine ? (
<p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">{executionProgressLine}</p>
) : null}
</section>
{clarificationQuestions.length > 0 ? (

View File

@@ -5,6 +5,10 @@ import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper";
type AgentOutputNodeData = {
isSkeleton?: boolean;
stepId?: string;
stepIndex?: number;
stepTotal?: number;
title?: string;
channel?: string;
outputType?: string;
@@ -17,6 +21,25 @@ type AgentOutputNodeType = Node<AgentOutputNodeData, "agent-output">;
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
const nodeData = data as AgentOutputNodeData;
const isSkeleton = nodeData.isSkeleton === true;
const hasStepCounter =
typeof nodeData.stepIndex === "number" &&
Number.isFinite(nodeData.stepIndex) &&
typeof nodeData.stepTotal === "number" &&
Number.isFinite(nodeData.stepTotal) &&
nodeData.stepTotal > 0;
const safeStepIndex =
typeof nodeData.stepIndex === "number" && Number.isFinite(nodeData.stepIndex)
? Math.max(0, Math.floor(nodeData.stepIndex))
: 0;
const safeStepTotal =
typeof nodeData.stepTotal === "number" && Number.isFinite(nodeData.stepTotal)
? Math.max(1, Math.floor(nodeData.stepTotal))
: 1;
const stepCounter = hasStepCounter
? `${safeStepIndex + 1}/${safeStepTotal}`
: null;
const resolvedTitle = nodeData.title ?? (isSkeleton ? "Planned output" : "Agent output");
return (
<BaseNodeWrapper
@@ -24,7 +47,7 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
selected={selected}
status={nodeData._status}
statusMessage={nodeData._statusMessage}
className="min-w-[300px] border-amber-500/30"
className={`min-w-[300px] border-amber-500/30 ${isSkeleton ? "opacity-80" : ""}`}
>
<Handle
type="target"
@@ -35,9 +58,22 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
<div className="flex h-full flex-col gap-3 p-3">
<header className="space-y-1">
<p className="truncate text-xs font-semibold text-foreground" title={nodeData.title}>
{nodeData.title ?? "Agent output"}
</p>
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-semibold text-foreground" title={resolvedTitle}>
{resolvedTitle}
</p>
{isSkeleton ? (
<span className="shrink-0 rounded-full border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-200">
Skeleton
</span>
) : null}
</div>
{isSkeleton ? (
<p className="text-[11px] text-amber-700/90 dark:text-amber-300/90">
Planned output{stepCounter ? ` - ${stepCounter}` : ""}
{nodeData.stepId ? ` - ${nodeData.stepId}` : ""}
</p>
) : null}
</header>
<section className="space-y-1">
@@ -62,9 +98,18 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Body
</p>
<p className="line-clamp-6 whitespace-pre-wrap text-xs text-foreground/90">
{nodeData.body ?? ""}
</p>
{isSkeleton ? (
<div
data-testid="agent-output-skeleton-body"
className="animate-pulse rounded-md border border-dashed border-amber-500/40 bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-amber-500/10 p-3"
>
<p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">Planned content</p>
</div>
) : (
<p className="line-clamp-6 whitespace-pre-wrap text-xs text-foreground/90">
{nodeData.body ?? ""}
</p>
)}
</section>
</div>
</BaseNodeWrapper>

View File

@@ -14,9 +14,11 @@ import { getNodeDataRecord } from "./ai_node_data";
import { formatTerminalStatusMessage } from "./ai_errors";
import {
areClarificationAnswersComplete,
normalizeAgentExecutionPlan,
normalizeAgentOutputDraft,
type AgentClarificationAnswerMap,
type AgentClarificationQuestion,
type AgentExecutionStep,
type AgentOutputDraft,
} from "../lib/agent-run-contract";
import {
@@ -31,7 +33,7 @@ import { normalizePublicTier } from "../lib/tier-credits";
const ANALYZE_SCHEMA: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: ["analysisSummary", "clarificationQuestions"],
required: ["analysisSummary", "clarificationQuestions", "executionPlan"],
properties: {
analysisSummary: { type: "string" },
clarificationQuestions: {
@@ -48,34 +50,65 @@ const ANALYZE_SCHEMA: Record<string, unknown> = {
},
},
},
},
};
const EXECUTE_SCHEMA: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: ["summary", "outputs"],
properties: {
summary: { type: "string" },
outputs: {
type: "array",
minItems: 1,
maxItems: 6,
items: {
type: "object",
additionalProperties: false,
required: ["title", "channel", "outputType", "body"],
properties: {
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
body: { type: "string" },
executionPlan: {
type: "object",
additionalProperties: false,
required: ["summary", "steps"],
properties: {
summary: { type: "string" },
steps: {
type: "array",
minItems: 1,
maxItems: 6,
items: {
type: "object",
additionalProperties: false,
required: ["id", "title", "channel", "outputType"],
properties: {
id: { type: "string" },
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
},
},
},
},
},
},
};
function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
const stepOutputProperties: Record<string, unknown> = {};
for (const stepId of stepIds) {
stepOutputProperties[stepId] = {
type: "object",
additionalProperties: false,
required: ["title", "channel", "outputType", "body"],
properties: {
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
body: { type: "string" },
},
};
}
return {
type: "object",
additionalProperties: false,
required: ["summary", "stepOutputs"],
properties: {
summary: { type: "string" },
stepOutputs: {
type: "object",
additionalProperties: false,
required: stepIds,
properties: stepOutputProperties,
},
},
};
}
type InternalApiShape = {
canvasGraph: {
getInternal: FunctionReference<
@@ -111,7 +144,6 @@ type InternalApiShape = {
nodeId: Id<"nodes">;
modelId: string;
userId: string;
analysisSummary: string;
reservationId?: Id<"creditTransactions">;
shouldDecrementConcurrency: boolean;
},
@@ -161,13 +193,37 @@ type InternalApiShape = {
{ nodeId: Id<"nodes">; statusMessage?: string },
unknown
>;
finalizeAgentSuccessWithOutputs: FunctionReference<
createExecutionSkeletonOutputs: FunctionReference<
"mutation",
"internal",
{
canvasId: Id<"canvases">;
nodeId: Id<"nodes">;
outputs: AgentOutputDraft[];
executionPlan: { summary: string; steps: AgentExecutionStep[] };
},
{ outputNodeIds: Id<"nodes">[] }
>;
completeExecutionStepOutput: FunctionReference<
"mutation",
"internal",
{
nodeId: Id<"nodes">;
outputNodeId: Id<"nodes">;
stepId: string;
stepIndex: number;
stepTotal: number;
title: string;
channel: string;
outputType: string;
body: string;
},
unknown
>;
finalizeAgentSuccessWithOutputs: FunctionReference<
"mutation",
"internal",
{
nodeId: Id<"nodes">;
summary: string;
},
{ outputNodeIds: Id<"nodes">[] }
@@ -258,6 +314,57 @@ function normalizeClarificationQuestions(raw: unknown): AgentClarificationQuesti
return questions;
}
type AgentExecutionStepRuntime = AgentExecutionStep & {
nodeId: Id<"nodes">;
stepIndex: number;
stepTotal: number;
};
function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
if (!Array.isArray(raw)) {
return [];
}
const steps: AgentExecutionStepRuntime[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const itemRecord = item as Record<string, unknown>;
const nodeId = trimText(itemRecord.nodeId);
const stepId = trimText(itemRecord.stepId);
const title = trimText(itemRecord.title);
const channel = trimText(itemRecord.channel);
const outputType = trimText(itemRecord.outputType);
const rawStepIndex = itemRecord.stepIndex;
const rawStepTotal = itemRecord.stepTotal;
const stepIndex =
typeof rawStepIndex === "number" && Number.isFinite(rawStepIndex)
? Math.max(0, Math.floor(rawStepIndex))
: -1;
const stepTotal =
typeof rawStepTotal === "number" && Number.isFinite(rawStepTotal)
? Math.max(0, Math.floor(rawStepTotal))
: 0;
if (!nodeId || !stepId || !title || !channel || !outputType || stepIndex < 0 || stepTotal <= 0) {
continue;
}
steps.push({
id: stepId,
title,
channel,
outputType,
nodeId: nodeId as Id<"nodes">,
stepIndex,
stepTotal,
});
}
return steps.sort((a, b) => a.stepIndex - b.stepIndex);
}
function serializeNodeDataForPrompt(data: unknown): string {
if (data === undefined) {
return "{}";
@@ -395,6 +502,8 @@ export const setAgentAnalyzing = internalMutation({
modelId: args.modelId,
reservationId: args.reservationId,
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
executionPlanSummary: undefined,
executionSteps: [],
},
});
},
@@ -455,6 +564,165 @@ export const setAgentExecuting = internalMutation({
},
});
export const createExecutionSkeletonOutputs = internalMutation({
args: {
canvasId: v.id("canvases"),
nodeId: v.id("nodes"),
executionPlan: v.object({
summary: v.string(),
steps: v.array(
v.object({
id: v.string(),
title: v.string(),
channel: v.string(),
outputType: v.string(),
}),
),
}),
},
handler: async (ctx, args) => {
const node = await ctx.db.get(args.nodeId);
if (!node) {
throw new Error("Node not found");
}
if (node.type !== "agent") {
throw new Error("Node must be an agent node");
}
if (node.canvasId !== args.canvasId) {
throw new Error("Agent node does not belong to canvas");
}
const prev = getNodeDataRecord(node.data);
const existingOutputNodeIds = Array.isArray(prev.outputNodeIds)
? prev.outputNodeIds.filter((value): value is Id<"nodes"> => typeof value === "string")
: [];
const baseX = node.positionX + node.width + 120;
const baseY = node.positionY;
const stepTotal = args.executionPlan.steps.length;
const outputNodeIds: Id<"nodes">[] = [];
const runtimeSteps: Array<{
stepId: string;
nodeId: Id<"nodes">;
stepIndex: number;
stepTotal: number;
title: string;
channel: string;
outputType: string;
}> = [];
for (let index = 0; index < args.executionPlan.steps.length; index += 1) {
const step = args.executionPlan.steps[index];
const outputNodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId,
type: "agent-output",
positionX: baseX,
positionY: baseY + index * 220,
width: 360,
height: 260,
status: "executing",
retryCount: 0,
data: {
isSkeleton: true,
stepId: step.id,
stepIndex: index,
stepTotal,
title: step.title,
channel: step.channel,
outputType: step.outputType,
body: "",
},
});
outputNodeIds.push(outputNodeId);
runtimeSteps.push({
stepId: step.id,
nodeId: outputNodeId,
stepIndex: index,
stepTotal,
title: step.title,
channel: step.channel,
outputType: step.outputType,
});
await ctx.db.insert("edges", {
canvasId: args.canvasId,
sourceNodeId: args.nodeId,
targetNodeId: outputNodeId,
sourceHandle: undefined,
targetHandle: "agent-output-in",
});
}
await ctx.db.patch(args.nodeId, {
data: {
...prev,
executionPlanSummary: trimText(args.executionPlan.summary),
executionSteps: runtimeSteps,
outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds],
},
});
await ctx.db.patch(args.canvasId, {
updatedAt: Date.now(),
});
return {
outputNodeIds,
};
},
});
export const completeExecutionStepOutput = internalMutation({
args: {
nodeId: v.id("nodes"),
outputNodeId: v.id("nodes"),
stepId: v.string(),
stepIndex: v.number(),
stepTotal: v.number(),
title: v.string(),
channel: v.string(),
outputType: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
const node = await ctx.db.get(args.nodeId);
if (!node) {
throw new Error("Node not found");
}
if (node.type !== "agent") {
throw new Error("Node must be an agent node");
}
const outputNode = await ctx.db.get(args.outputNodeId);
if (!outputNode) {
throw new Error("Output node not found");
}
if (outputNode.type !== "agent-output") {
throw new Error("Node must be an agent-output node");
}
if (outputNode.canvasId !== node.canvasId) {
throw new Error("Output node does not belong to the same canvas");
}
await ctx.db.patch(args.outputNodeId, {
status: "done",
statusMessage: undefined,
retryCount: 0,
data: {
isSkeleton: false,
stepId: trimText(args.stepId),
stepIndex: Math.max(0, Math.floor(args.stepIndex)),
stepTotal: Math.max(1, Math.floor(args.stepTotal)),
title: trimText(args.title),
channel: trimText(args.channel),
outputType: trimText(args.outputType),
body: trimText(args.body),
},
});
},
});
export const setAgentError = internalMutation({
args: {
nodeId: v.id("nodes"),
@@ -517,16 +785,7 @@ export const upsertClarificationAnswers = internalMutation({
export const finalizeAgentSuccessWithOutputs = internalMutation({
args: {
canvasId: v.id("canvases"),
nodeId: v.id("nodes"),
outputs: v.array(
v.object({
title: v.optional(v.string()),
channel: v.optional(v.string()),
outputType: v.optional(v.string()),
body: v.optional(v.string()),
}),
),
summary: v.string(),
},
handler: async (ctx, args) => {
@@ -537,48 +796,12 @@ export const finalizeAgentSuccessWithOutputs = internalMutation({
if (node.type !== "agent") {
throw new Error("Node must be an agent node");
}
if (node.canvasId !== args.canvasId) {
throw new Error("Agent node does not belong to canvas");
}
const prev = getNodeDataRecord(node.data);
const existingOutputNodeIds = Array.isArray(prev.outputNodeIds)
? prev.outputNodeIds.filter((value): value is Id<"nodes"> => typeof value === "string")
: [];
const baseX = node.positionX + node.width + 120;
const baseY = node.positionY;
const outputNodeIds: Id<"nodes">[] = [];
for (let index = 0; index < args.outputs.length; index += 1) {
const normalized = normalizeAgentOutputDraft(args.outputs[index] ?? {});
const outputNodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId,
type: "agent-output",
positionX: baseX,
positionY: baseY + index * 220,
width: 360,
height: 260,
status: "done",
retryCount: 0,
data: {
title: normalized.title,
channel: normalized.channel,
outputType: normalized.outputType,
body: normalized.body,
},
});
outputNodeIds.push(outputNodeId);
await ctx.db.insert("edges", {
canvasId: args.canvasId,
sourceNodeId: args.nodeId,
targetNodeId: outputNodeId,
sourceHandle: undefined,
targetHandle: "agent-output-in",
});
}
await ctx.db.patch(args.nodeId, {
status: "done",
statusMessage: undefined,
@@ -586,19 +809,19 @@ export const finalizeAgentSuccessWithOutputs = internalMutation({
data: {
...prev,
clarificationQuestions: [],
outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds],
outputNodeIds: existingOutputNodeIds,
lastRunSummary: trimText(args.summary),
reservationId: undefined,
shouldDecrementConcurrency: undefined,
},
});
await ctx.db.patch(args.canvasId, {
await ctx.db.patch(node.canvasId, {
updatedAt: Date.now(),
});
return {
outputNodeIds,
outputNodeIds: existingOutputNodeIds,
};
},
});
@@ -632,6 +855,7 @@ export const analyzeAgent = internalAction({
const analysis = await generateStructuredObjectViaOpenRouter<{
analysisSummary: string;
clarificationQuestions: AgentClarificationQuestion[];
executionPlan: unknown;
}>(apiKey, {
model: args.modelId,
schemaName: "agent_analyze_result",
@@ -659,6 +883,10 @@ export const analyzeAgent = internalAction({
const clarificationQuestions = normalizeClarificationQuestions(
analysis.clarificationQuestions,
);
const executionPlan = normalizeAgentExecutionPlan(analysis.executionPlan);
if (executionPlan.steps.length === 0) {
throw new Error("Agent analyze returned an empty execution plan");
}
const hasRequiredGaps = !areClarificationAnswersComplete(
clarificationQuestions,
existingAnswers,
@@ -672,6 +900,12 @@ export const analyzeAgent = internalAction({
return;
}
await ctx.runMutation(internalApi.agents.createExecutionSkeletonOutputs, {
canvasId: args.canvasId,
nodeId: args.nodeId,
executionPlan,
});
await ctx.runMutation(internalApi.agents.setAgentExecuting, {
nodeId: args.nodeId,
});
@@ -681,7 +915,6 @@ export const analyzeAgent = internalAction({
nodeId: args.nodeId,
modelId: args.modelId,
userId: args.userId,
analysisSummary: trimText(analysis.analysisSummary),
reservationId: args.reservationId,
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
});
@@ -702,7 +935,6 @@ export const executeAgent = internalAction({
nodeId: v.id("nodes"),
modelId: v.string(),
userId: v.string(),
analysisSummary: v.string(),
reservationId: v.optional(v.id("creditTransactions")),
shouldDecrementConcurrency: v.boolean(),
},
@@ -723,27 +955,43 @@ export const executeAgent = internalAction({
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const incomingContext = collectIncomingContext(graph, args.nodeId);
const executionPlanSummary = trimText(agentData.executionPlanSummary);
const executionSteps = normalizeExecutionSteps(agentData.executionSteps);
if (executionSteps.length === 0) {
throw new Error("Agent execute is missing execution steps");
}
const executeSchema = buildExecuteSchema(executionSteps.map((step) => step.id));
const execution = await generateStructuredObjectViaOpenRouter<{
summary: string;
outputs: AgentOutputDraft[];
stepOutputs: Record<string, AgentOutputDraft>;
}>(apiKey, {
model: args.modelId,
schemaName: "agent_execute_result",
schema: EXECUTE_SCHEMA,
schema: executeSchema,
messages: [
{
role: "system",
content:
"You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Output concise, actionable drafts.",
"You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Return one output per step, keyed by stepId.",
},
{
role: "user",
content: [
`Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`,
`Analyze summary: ${trimText(args.analysisSummary)}`,
`Analyze summary: ${executionPlanSummary}`,
`Clarification answers: ${JSON.stringify(clarificationAnswers)}`,
`Execution steps: ${JSON.stringify(
executionSteps.map((step) => ({
id: step.id,
title: step.title,
channel: step.channel,
outputType: step.outputType,
})),
)}`,
"Incoming node context:",
incomingContext,
"Return structured JSON matching the schema.",
@@ -752,15 +1000,45 @@ export const executeAgent = internalAction({
],
});
const outputs = Array.isArray(execution.outputs) ? execution.outputs : [];
if (outputs.length === 0) {
throw new Error("Agent execution returned no outputs");
const stepOutputs =
execution.stepOutputs && typeof execution.stepOutputs === "object"
? execution.stepOutputs
: {};
for (let index = 0; index < executionSteps.length; index += 1) {
const step = executionSteps[index];
await ctx.runMutation(internalApi.agents.setAgentExecuting, {
nodeId: args.nodeId,
statusMessage: `Generating ${step.title} ${step.stepIndex + 1}/${step.stepTotal}`,
});
const rawOutput = stepOutputs[step.id];
if (!rawOutput || typeof rawOutput !== "object") {
throw new Error(`Missing execution output for step ${step.id}`);
}
const normalized = normalizeAgentOutputDraft({
...rawOutput,
title: trimText(rawOutput.title) || step.title,
channel: trimText(rawOutput.channel) || step.channel,
outputType: trimText(rawOutput.outputType) || step.outputType,
});
await ctx.runMutation(internalApi.agents.completeExecutionStepOutput, {
nodeId: args.nodeId,
outputNodeId: step.nodeId,
stepId: step.id,
stepIndex: step.stepIndex,
stepTotal: step.stepTotal,
title: normalized.title,
channel: normalized.channel,
outputType: normalized.outputType,
body: normalized.body,
});
}
await ctx.runMutation(internalApi.agents.finalizeAgentSuccessWithOutputs, {
canvasId: args.canvasId,
nodeId: args.nodeId,
outputs,
summary: execution.summary,
});

View File

@@ -13,9 +13,16 @@ export type AgentOutputDraft = {
body?: string;
};
export type AgentExecutionStep = {
id: string;
title: string;
channel: string;
outputType: string;
};
export type AgentExecutionPlan = {
steps: string[];
outputs: AgentOutputDraft[];
summary: string;
steps: AgentExecutionStep[];
};
export type AgentAnalyzeResult = {
@@ -32,6 +39,53 @@ function trimString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeStepId(value: unknown): string {
return trimString(value)
.toLowerCase()
.replace(/[^a-z0-9\s-_]/g, "")
.replace(/\s+/g, "-");
}
export function normalizeAgentExecutionPlan(raw: unknown): AgentExecutionPlan {
const rawRecord =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: null;
const rawSteps = Array.isArray(rawRecord?.steps) ? rawRecord.steps : [];
const seenStepIds = new Set<string>();
const steps: AgentExecutionStep[] = [];
for (let index = 0; index < rawSteps.length; index += 1) {
const item = rawSteps[index];
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const itemRecord = item as Record<string, unknown>;
const fallbackId = `step-${index + 1}`;
const normalizedCandidateId = normalizeStepId(itemRecord.id) || fallbackId;
let stepId = normalizedCandidateId;
let suffix = 2;
while (seenStepIds.has(stepId)) {
stepId = `${normalizedCandidateId}-${suffix}`;
suffix += 1;
}
seenStepIds.add(stepId);
steps.push({
id: stepId,
title: trimString(itemRecord.title) || SAFE_FALLBACK_TITLE,
channel: trimString(itemRecord.channel) || SAFE_FALLBACK_CHANNEL,
outputType: trimString(itemRecord.outputType) || SAFE_FALLBACK_OUTPUT_TYPE,
});
}
return {
summary: trimString(rawRecord?.summary),
steps,
};
}
export function areClarificationAnswersComplete(
questions: AgentClarificationQuestion[],
answers: AgentClarificationAnswerMap,

View File

@@ -293,4 +293,51 @@ describe("AgentNode runtime", () => {
expect(mocks.runAgent).not.toHaveBeenCalled();
expect(mocks.resumeAgent).not.toHaveBeenCalled();
});
it("disables run button and shows progress while executing", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentNode, {
id: "agent-3",
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",
_statusMessage: "Executing step 2/4",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
const runButton = Array.from(container.querySelectorAll("button")).find((element) =>
element.textContent?.includes("Run agent"),
);
if (!(runButton instanceof HTMLButtonElement)) {
throw new Error("Run button not found");
}
expect(runButton.disabled).toBe(true);
expect(container.textContent).toContain("Executing step 2/4");
await act(async () => {
runButton.click();
});
expect(mocks.runAgent).not.toHaveBeenCalled();
});
});

View File

@@ -45,7 +45,7 @@ describe("AgentNode", () => {
root = null;
});
it("renders campaign distributor metadata and input-only handle", async () => {
it("renders campaign distributor metadata and source/target handles", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -76,7 +76,7 @@ describe("AgentNode", () => {
expect(container.textContent).toContain("Instagram Feed");
expect(container.textContent).toContain("Caption-Pakete");
expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1);
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(0);
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1);
});
it("falls back to the default template when templateId is missing", async () => {

View File

@@ -112,4 +112,41 @@ describe("AgentOutputNode", () => {
expect(handleCalls).toEqual([{ type: "target", id: "agent-output-in" }]);
});
it("renders skeleton mode with counter and placeholder", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentOutputNode, {
id: "agent-output-3",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent-output",
data: {
title: "Planned headline",
channel: "linkedin",
outputType: "post",
isSkeleton: true,
stepIndex: 1,
stepTotal: 4,
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(container.textContent).toContain("Skeleton");
expect(container.textContent).toContain("2/4");
expect(container.querySelector('[data-testid="agent-output-skeleton-body"]')).not.toBeNull();
expect(handleCalls).toEqual([{ type: "target", id: "agent-output-in" }]);
});
});

View File

@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import {
areClarificationAnswersComplete,
normalizeAgentExecutionPlan,
normalizeAgentOutputDraft,
type AgentClarificationAnswerMap,
type AgentClarificationQuestion,
type AgentExecutionPlan,
} from "@/lib/agent-run-contract";
describe("agent run contract helpers", () => {
@@ -93,4 +95,87 @@ describe("agent run contract helpers", () => {
expect(normalized.body).toBe("");
});
});
describe("normalizeAgentExecutionPlan", () => {
it("trims summary and step metadata while preserving valid values", () => {
const normalized = normalizeAgentExecutionPlan({
summary: " Ship a launch kit ",
steps: [
{
id: " STEP-1 ",
title: " Instagram captions ",
channel: " Instagram ",
outputType: " caption-pack ",
},
],
});
expect(normalized).toEqual<AgentExecutionPlan>({
summary: "Ship a launch kit",
steps: [
{
id: "step-1",
title: "Instagram captions",
channel: "Instagram",
outputType: "caption-pack",
},
],
});
});
it("falls back to safe defaults for invalid payloads", () => {
const normalized = normalizeAgentExecutionPlan({
summary: null,
steps: [
{
id: "",
title: "",
channel: " ",
outputType: undefined,
},
null,
],
});
expect(normalized).toEqual<AgentExecutionPlan>({
summary: "",
steps: [
{
id: "step-1",
title: "Untitled",
channel: "general",
outputType: "text",
},
],
});
});
it("deduplicates step ids and creates deterministic fallback ids", () => {
const normalized = normalizeAgentExecutionPlan({
summary: "ready",
steps: [
{
id: "step",
title: "One",
channel: "email",
outputType: "copy",
},
{
id: "step",
title: "Two",
channel: "x",
outputType: "thread",
},
{
id: "",
title: "Three",
channel: "linkedin",
outputType: "post",
},
],
});
expect(normalized.steps.map((step) => step.id)).toEqual(["step", "step-2", "step-3"]);
});
});
});