From 26d008705f1e271cf484d3926f9d137f217bcd78 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Thu, 9 Apr 2026 21:11:21 +0200 Subject: [PATCH] feat(agent): add execution-plan skeleton workflow --- components/canvas/nodes/agent-node.tsx | 68 ++- components/canvas/nodes/agent-output-node.tsx | 59 ++- convex/agents.ts | 448 ++++++++++++++---- lib/agent-run-contract.ts | 58 ++- tests/agent-node-runtime.test.ts | 47 ++ tests/agent-node.test.ts | 4 +- tests/agent-output-node.test.ts | 37 ++ tests/lib/agent-run-contract.test.ts | 85 ++++ 8 files changed, 708 insertions(+), 98 deletions(-) diff --git a/components/canvas/nodes/agent-node.tsx b/components/canvas/nodes/agent-node.tsx index 9ddc6ea..8d283ad 100644 --- a/components/canvas/nodes/agent-node.tsx +++ b/components/canvas/nodes/agent-node.tsx @@ -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 { + 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) => { @@ -239,6 +289,10 @@ export default function AgentNode({ id, data, selected }: NodeProps { + if (isExecutionActive) { + return; + } + if (status.isOffline) { toast.warning( "Offline aktuell nicht unterstuetzt", @@ -257,7 +311,7 @@ export default function AgentNode({ id, data, selected }: NodeProps, 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 +
@@ -334,10 +393,15 @@ export default function AgentNode({ id, data, selected }: NodeProps 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 + {executionProgressLine ? ( +

{executionProgressLine}

+ ) : null} {clarificationQuestions.length > 0 ? ( diff --git a/components/canvas/nodes/agent-output-node.tsx b/components/canvas/nodes/agent-output-node.tsx index 6afbf1c..ec4fb16 100644 --- a/components/canvas/nodes/agent-output-node.tsx +++ b/components/canvas/nodes/agent-output-node.tsx @@ -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; export default function AgentOutputNode({ data, selected }: NodeProps) { 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 (
-

- {nodeData.title ?? "Agent output"} -

+
+

+ {resolvedTitle} +

+ {isSkeleton ? ( + + Skeleton + + ) : null} +
+ {isSkeleton ? ( +

+ Planned output{stepCounter ? ` - ${stepCounter}` : ""} + {nodeData.stepId ? ` - ${nodeData.stepId}` : ""} +

+ ) : null}
@@ -62,9 +98,18 @@ export default function AgentOutputNode({ data, selected }: NodeProps Body

-

- {nodeData.body ?? ""} -

+ {isSkeleton ? ( +
+

Planned content

+
+ ) : ( +

+ {nodeData.body ?? ""} +

+ )}
diff --git a/convex/agents.ts b/convex/agents.ts index 707aa17..980e962 100644 --- a/convex/agents.ts +++ b/convex/agents.ts @@ -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 = { type: "object", additionalProperties: false, - required: ["analysisSummary", "clarificationQuestions"], + required: ["analysisSummary", "clarificationQuestions", "executionPlan"], properties: { analysisSummary: { type: "string" }, clarificationQuestions: { @@ -48,34 +50,65 @@ const ANALYZE_SCHEMA: Record = { }, }, }, - }, -}; - -const EXECUTE_SCHEMA: Record = { - 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 { + const stepOutputProperties: Record = {}; + 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; + 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; }>(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, }); diff --git a/lib/agent-run-contract.ts b/lib/agent-run-contract.ts index c2a3f0a..220bc96 100644 --- a/lib/agent-run-contract.ts +++ b/lib/agent-run-contract.ts @@ -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) + : null; + const rawSteps = Array.isArray(rawRecord?.steps) ? rawRecord.steps : []; + const seenStepIds = new Set(); + 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; + 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, diff --git a/tests/agent-node-runtime.test.ts b/tests/agent-node-runtime.test.ts index ca67c3e..bf5bf6b 100644 --- a/tests/agent-node-runtime.test.ts +++ b/tests/agent-node-runtime.test.ts @@ -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, + 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(); + }); }); diff --git a/tests/agent-node.test.ts b/tests/agent-node.test.ts index 50893f4..d6a64d4 100644 --- a/tests/agent-node.test.ts +++ b/tests/agent-node.test.ts @@ -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 () => { diff --git a/tests/agent-output-node.test.ts b/tests/agent-output-node.test.ts index b002c9d..4276cbd 100644 --- a/tests/agent-output-node.test.ts +++ b/tests/agent-output-node.test.ts @@ -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, + 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" }]); + }); }); diff --git a/tests/lib/agent-run-contract.test.ts b/tests/lib/agent-run-contract.test.ts index d509f0f..4d16b1c 100644 --- a/tests/lib/agent-run-contract.test.ts +++ b/tests/lib/agent-run-contract.test.ts @@ -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({ + 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({ + 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"]); + }); + }); });