fix(ai) - workflow tool outputs optim + display fix (#21500)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21500?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-06-16 10:50:16 +02:00
committed by GitHub
parent 12b1dba986
commit ceb7698689
7 changed files with 286 additions and 10 deletions
@@ -198,6 +198,8 @@ export const useAgentChatSubscription = (threadId: string | null) => {
const startReadLoop = async (readable: ReadableStream<UIMessageChunk>) => {
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;
@@ -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
@@ -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<typeof createUpdateWorkflowVersionStepTool>['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<string, unknown>;
expect(
workflowValidationService.validateWorkflowVersion,
).toHaveBeenCalled();
const validation = result.validation as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(result.validationError).toBe('boom');
expect(result).not.toHaveProperty('validation');
});
});
@@ -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, {{<step-id>.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: [
{
@@ -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 {
@@ -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',
);
});
});
@@ -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;
};