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) Review in cubic --- .../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; +};