From ceb76986892ac6c4698da327ea81cfc987d9ea5c Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Tue, 16 Jun 2026 10:50:16 +0200
Subject: [PATCH] fix(ai) - workflow tool outputs optim + display fix (#21500)
---
.../ai/hooks/useAgentChatSubscription.ts | 10 +-
...reate-standard-flat-skill-metadata.util.ts | 8 +-
.../update-workflow-version-step.tool.spec.ts | 105 ++++++++++++++++++
.../tools/create-complete-workflow.tool.ts | 10 +-
.../update-workflow-version-step.tool.ts | 23 +++-
.../summarize-validation.util.spec.ts | 97 ++++++++++++++++
.../utils/summarize-validation.util.ts | 43 +++++++
7 files changed, 286 insertions(+), 10 deletions(-)
create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/__tests__/update-workflow-version-step.tool.spec.ts
create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/utils/__tests__/summarize-validation.util.spec.ts
create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/utils/summarize-validation.util.ts
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts
index 9c97530909..7ad977eebd 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts
@@ -198,6 +198,8 @@ export const useAgentChatSubscription = (threadId: string | null) => {
const startReadLoop = async (readable: ReadableStream) => {
const messageStream = readUIMessageStream({ stream: readable });
+ let lastUsageCountedMessageId: string | null = null;
+
for await (const message of messageStream) {
const extendedMessage = message as ExtendedUIMessage;
@@ -225,7 +227,13 @@ export const useAgentChatSubscription = (threadId: string | null) => {
}
| undefined;
- if (isDefined(metadata?.usage) && isDefined(metadata?.model)) {
+ if (
+ isDefined(metadata?.usage) &&
+ isDefined(metadata?.model) &&
+ lastUsageCountedMessageId !== extendedMessage.id
+ ) {
+ lastUsageCountedMessageId = extendedMessage.id;
+
const usage = metadata.usage;
const model = metadata.model;
diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts
index ff3c58e3a3..392a7f2506 100644
--- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts
@@ -74,9 +74,13 @@ Always rely on tool schema definitions:
- Follow schema definitions exactly for field names, types, and structures
- Schema includes validation rules and common patterns
-## Validation
+## Validation Strategy
-The \`create_complete_workflow\` and \`update_workflow_version_step\` tools automatically run validation after their operation and include the results in the response. Review any reported errors and fix them before activating the workflow.
+Build steps fully configured up front so the workflow is correct on the first try. Mutation tools (\`create_complete_workflow\`, \`update_workflow_version_step\`) return a compact validation summary (error codes, messages, suggestions) — fix any reported errors.
+
+Do NOT call \`validate_workflow\` after every change:
+- When making several step edits in a row, pass \`validate: false\` to \`update_workflow_version_step\` to skip per-edit validation.
+- Call \`validate_workflow\` exactly ONCE at the end, before activating. It returns the full report including warnings and available variable paths.
## Approach
diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/__tests__/update-workflow-version-step.tool.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/__tests__/update-workflow-version-step.tool.spec.ts
new file mode 100644
index 0000000000..8b872b1834
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/__tests__/update-workflow-version-step.tool.spec.ts
@@ -0,0 +1,105 @@
+import { createUpdateWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool';
+
+const mockStep = {
+ id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ name: 'Send email',
+ type: 'SEND_EMAIL',
+ valid: true,
+ settings: { input: {} },
+};
+
+const buildTool = ({
+ validationResult = { valid: true, errors: [], warnings: [] },
+}: {
+ validationResult?: object;
+} = {}) => {
+ const workflowVersionStepService = {
+ updateWorkflowVersionStep: jest.fn().mockResolvedValue(mockStep),
+ };
+ const workflowValidationService = {
+ validateWorkflowVersion: jest.fn().mockResolvedValue(validationResult),
+ };
+
+ const tool = createUpdateWorkflowVersionStepTool(
+ {
+ workflowVersionStepService,
+ workflowValidationService,
+ } as never,
+ { workspaceId: 'workspace-id' },
+ );
+
+ return { tool, workflowVersionStepService, workflowValidationService };
+};
+
+const baseInput = {
+ workflowVersionId: 'b3b8a4f0-0000-4000-8000-000000000000',
+ step: mockStep,
+} as unknown as Parameters<
+ ReturnType['execute']
+>[0];
+
+describe('createUpdateWorkflowVersionStepTool', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should validate by default and return a compact summary', async () => {
+ const { tool, workflowValidationService } = buildTool({
+ validationResult: {
+ valid: false,
+ errors: [
+ {
+ severity: 'error',
+ code: 'DANGLING_REFERENCE',
+ message: 'Unknown variable',
+ availablePaths: ['{{trigger.x}}'],
+ },
+ ],
+ warnings: [{ severity: 'warning', code: 'NO_STEPS', message: 'w' }],
+ },
+ });
+
+ const result = (await tool.execute(baseInput)) as Record;
+
+ expect(
+ workflowValidationService.validateWorkflowVersion,
+ ).toHaveBeenCalled();
+
+ const validation = result.validation as Record;
+
+ expect(validation.valid).toBe(false);
+ expect(validation.errorCount).toBe(1);
+ expect(validation.warningCount).toBe(1);
+ expect(validation).not.toHaveProperty('warnings');
+ expect((validation.errors as object[])[0]).not.toHaveProperty(
+ 'availablePaths',
+ );
+ });
+
+ it('should skip validation entirely when validate is false', async () => {
+ const { tool, workflowValidationService } = buildTool();
+
+ const result = (await tool.execute({
+ ...baseInput,
+ validate: false,
+ })) as Record;
+
+ expect(
+ workflowValidationService.validateWorkflowVersion,
+ ).not.toHaveBeenCalled();
+ expect(result).not.toHaveProperty('validation');
+ });
+
+ it('should still return the step result when validation throws', async () => {
+ const { tool, workflowValidationService } = buildTool();
+
+ workflowValidationService.validateWorkflowVersion.mockRejectedValue(
+ new Error('boom'),
+ );
+
+ const result = (await tool.execute(baseInput)) as Record;
+
+ expect(result.validationError).toBe('boom');
+ expect(result).not.toHaveProperty('validation');
+ });
+});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts
index 1d685c3d99..0e7f6d6acf 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts
@@ -18,6 +18,7 @@ import {
type WorkflowToolContext,
type WorkflowToolDependencies,
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
+import { summarizeValidation } from 'src/modules/workflow/workflow-tools/utils/summarize-validation.util';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
const createCompleteWorkflowSchema = z.object({
@@ -106,7 +107,9 @@ IMPORTANT: The tool schema provides comprehensive field descriptions, examples,
- Variable reference syntax: {{trigger.fieldName}} for trigger data, {{.result.fieldName}} for step outputs (step-id is the step's UUID, not its name)
- Error handling options
-This is the most efficient way for AI to create workflows as it handles all the complexity in one call.`,
+This is the most efficient way for AI to create workflows as it handles all the complexity in one call.
+
+The response includes a compact validation summary. For the full validation report with available variable paths, call validate_workflow once after your edits — not after every change.`,
inputSchema: createCompleteWorkflowSchema,
execute: async (parameters: {
name: string;
@@ -197,9 +200,8 @@ This is the most efficient way for AI to create workflows as it handles all the
workflowId,
workflowVersionId,
name: parameters.name,
- trigger: parameters.trigger,
- steps: parameters.steps,
- validation,
+ stepIds: parameters.steps.map((step) => step.id),
+ validation: summarizeValidation(validation),
},
recordReferences: [
{
diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts
index b5099758a5..7df70c4208 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts
@@ -6,6 +6,7 @@ import {
type WorkflowToolContext,
type WorkflowToolDependencies,
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
+import { summarizeValidation } from 'src/modules/workflow/workflow-tools/utils/summarize-validation.util';
const updateWorkflowVersionStepSchema = z.object({
workflowVersionId: z
@@ -15,8 +16,19 @@ const updateWorkflowVersionStepSchema = z.object({
step: z
.union([workflowActionSchema])
.describe('The updated step configuration'),
+ validate: z
+ .boolean()
+ .optional()
+ .default(true)
+ .describe(
+ 'Run a quick validation and return a compact summary (default true). Set to false when making several edits in a row, then call validate_workflow once at the end instead.',
+ ),
});
+type UpdateWorkflowVersionStepToolInput = UpdateWorkflowVersionStepInput & {
+ validate?: boolean;
+};
+
export const createUpdateWorkflowVersionStepTool = (
deps: Pick<
WorkflowToolDependencies,
@@ -26,9 +38,9 @@ export const createUpdateWorkflowVersionStepTool = (
) => ({
name: 'update_workflow_version_step' as const,
description:
- 'Update an existing step in a workflow version. This modifies the step configuration.',
+ 'Update an existing step in a workflow version. This modifies the step configuration. Returns a compact validation summary; for the full report with available variable paths, call validate_workflow once after your edits — not after every change.',
inputSchema: updateWorkflowVersionStepSchema,
- execute: async (parameters: UpdateWorkflowVersionStepInput) => {
+ execute: async (parameters: UpdateWorkflowVersionStepToolInput) => {
let result;
try {
@@ -45,15 +57,20 @@ export const createUpdateWorkflowVersionStepTool = (
};
}
+ if (parameters.validate === false) {
+ return result;
+ }
+
try {
const validation =
await deps.workflowValidationService.validateWorkflowVersion({
workspaceId: context.workspaceId,
workflowVersionId: parameters.workflowVersionId,
});
+
return {
...result,
- validation,
+ validation: summarizeValidation(validation),
};
} catch (error) {
return {
diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/utils/__tests__/summarize-validation.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/utils/__tests__/summarize-validation.util.spec.ts
new file mode 100644
index 0000000000..57b7999d7b
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-tools/utils/__tests__/summarize-validation.util.spec.ts
@@ -0,0 +1,97 @@
+import { type WorkflowValidationResult } from 'twenty-shared/workflow';
+
+import { summarizeValidation } from 'src/modules/workflow/workflow-tools/utils/summarize-validation.util';
+
+describe('summarizeValidation', () => {
+ it('should return a minimal summary for a valid workflow', () => {
+ const result: WorkflowValidationResult = {
+ valid: true,
+ errors: [],
+ warnings: [],
+ };
+
+ expect(summarizeValidation(result)).toEqual({
+ valid: true,
+ errorCount: 0,
+ warningCount: 0,
+ errors: [],
+ });
+ });
+
+ it('should keep error essentials and drop availablePaths and hint', () => {
+ const result: WorkflowValidationResult = {
+ valid: false,
+ errors: [
+ {
+ severity: 'error',
+ code: 'DANGLING_REFERENCE',
+ message: 'Unknown variable {{step-1.foo}}',
+ stepId: 'step-2',
+ path: 'settings.input.body',
+ hint: 'some long hint',
+ suggestions: ['{{step-1.bar}}'],
+ availablePaths: ['{{step-1.bar}}', '{{step-1.baz}}', '{{trigger.x}}'],
+ },
+ ],
+ warnings: [],
+ };
+
+ const summary = summarizeValidation(result);
+
+ expect(summary.valid).toBe(false);
+ expect(summary.errorCount).toBe(1);
+ expect(summary.errors[0]).toEqual({
+ code: 'DANGLING_REFERENCE',
+ message: 'Unknown variable {{step-1.foo}}',
+ stepId: 'step-2',
+ path: 'settings.input.body',
+ suggestions: ['{{step-1.bar}}'],
+ });
+ expect(summary.errors[0]).not.toHaveProperty('availablePaths');
+ expect(summary.errors[0]).not.toHaveProperty('hint');
+ });
+
+ it('should collapse warnings to a count', () => {
+ const result: WorkflowValidationResult = {
+ valid: true,
+ errors: [],
+ warnings: [
+ {
+ severity: 'warning',
+ code: 'UNREACHABLE_STEP',
+ message: 'Step is unreachable',
+ },
+ {
+ severity: 'warning',
+ code: 'TRIGGER_HAS_NO_NEXT_STEP',
+ message: 'Trigger has no next step',
+ },
+ ],
+ };
+
+ const summary = summarizeValidation(result);
+
+ expect(summary.warningCount).toBe(2);
+ expect(summary).not.toHaveProperty('warnings');
+ expect(summary.hint).toContain('validate_workflow');
+ });
+
+ it('should omit empty suggestions', () => {
+ const result: WorkflowValidationResult = {
+ valid: false,
+ errors: [
+ {
+ severity: 'error',
+ code: 'NO_STEPS',
+ message: 'The workflow has no steps.',
+ suggestions: [],
+ },
+ ],
+ warnings: [],
+ };
+
+ expect(summarizeValidation(result).errors[0]).not.toHaveProperty(
+ 'suggestions',
+ );
+ });
+});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/utils/summarize-validation.util.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/utils/summarize-validation.util.ts
new file mode 100644
index 0000000000..812fa410a8
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-tools/utils/summarize-validation.util.ts
@@ -0,0 +1,43 @@
+import {
+ type WorkflowValidationIssue,
+ type WorkflowValidationResult,
+} from 'twenty-shared/workflow';
+
+export type WorkflowValidationSummary = {
+ valid: boolean;
+ errorCount: number;
+ warningCount: number;
+ errors: Array<
+ Pick<
+ WorkflowValidationIssue,
+ 'code' | 'stepId' | 'path' | 'message' | 'suggestions'
+ >
+ >;
+ hint?: string;
+};
+
+export const summarizeValidation = (
+ result: WorkflowValidationResult,
+): WorkflowValidationSummary => {
+ const summary: WorkflowValidationSummary = {
+ valid: result.valid,
+ errorCount: result.errors.length,
+ warningCount: result.warnings.length,
+ errors: result.errors.map(
+ ({ code, stepId, path, message, suggestions }) => ({
+ code,
+ stepId,
+ path,
+ message,
+ ...(suggestions && suggestions.length > 0 ? { suggestions } : {}),
+ }),
+ ),
+ };
+
+ if (!result.valid || result.warnings.length > 0) {
+ summary.hint =
+ 'Compact summary. For the full report including warnings and available variable paths, call validate_workflow.';
+ }
+
+ return summary;
+};