feat: Configure workflow tool inputs for Agents (#40285)

This commit is contained in:
Robin Braumann 2026-10-06 10:02:23 +00:00 committed by GitHub
parent 05a992d4f2
commit 4cb98aea9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1333 additions and 442 deletions

View File

@ -659,18 +659,21 @@ describe('formatAgentConfigZodError', () => {
});
describe('WorkflowToolJsonConfigSchema — inputs', () => {
it('accepts AI and fixed input bindings on a workflow tool', () => {
it('round-trips workflow input bindings', () => {
const inputs = {
chatId: { mode: 'ai' },
query: { mode: 'ai', description: 'Input guidance' },
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
label: { mode: 'fixed', value: '={{ literal }}' },
count: { mode: 'expression', value: '={{ 1 + 2 }}' },
};
const result = AgentJsonConfigSchema.safeParse({
...minimalConfig,
tools: [
{
type: 'workflow',
workflow: 'Show Shopping List',
inputs: {
chatId: { mode: 'ai' },
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
botName: { mode: 'fixed', value: 'Jarvis' },
},
inputs,
},
],
});
@ -679,16 +682,17 @@ describe('WorkflowToolJsonConfigSchema — inputs', () => {
if (result.success) {
expect(result.data.tools?.[0]).toMatchObject({
type: 'workflow',
inputs: {
chatId: { mode: 'ai' },
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
botName: { mode: 'fixed', value: 'Jarvis' },
},
inputs,
});
}
});
it('rejects a fixed binding without a value', () => {
it.each([
{ mode: 'fixed' },
{ mode: 'expression' },
{ mode: 'expression', value: '{{ 1 }}' },
{ mode: 'expression', value: '= ' },
])('rejects an invalid binding: %j', (binding) => {
const result = AgentJsonConfigSchema.safeParse({
...minimalConfig,
tools: [
@ -696,7 +700,7 @@ describe('WorkflowToolJsonConfigSchema — inputs', () => {
type: 'workflow',
workflow: 'Show Shopping List',
inputs: {
botName: { mode: 'fixed' },
botName: binding,
},
},
],

View File

@ -378,11 +378,12 @@ const CustomToolJsonConfigSchema = z.object({
/**
* Per-field binding for a workflow tool's Execute Workflow Trigger inputs.
* - `ai`: field is advertised to the LLM and must be supplied at call time.
* - `ai`: field is advertised to the LLM with optional input guidance.
* - `fixed`: field is omitted from the LLM schema and injected at invoke time.
* - `expression`: field is omitted from the LLM schema and resolved at invoke time.
*/
export const WorkflowToolInputFieldSchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('ai') }).strict(),
z.object({ mode: z.literal('ai'), description: z.string().optional() }).strict(),
z
.object({
mode: z.literal('fixed'),
@ -390,6 +391,12 @@ export const WorkflowToolInputFieldSchema = z.discriminatedUnion('mode', [
value: jsonValueSchema,
})
.strict(),
z
.object({
mode: z.literal('expression'),
value: z.string().regex(/^=\s*\S/, 'Enter an expression that starts with ='),
})
.strict(),
]);
export const WorkflowToolJsonConfigSchema = z

View File

@ -68,13 +68,13 @@ describe('createDeepLazyProxy', () => {
});
it('toString() returns "[object Object]"', () => {
const p = proxy();
const p = proxy(undefined, []);
expect(p.toString()).toBe('[object Object]');
expect(mocks.getValueAtPath).not.toHaveBeenCalled();
});
it('valueOf() returns the proxy target', () => {
const p = proxy();
const p = proxy(undefined, []);
const val = p.valueOf();
expect(typeof val).toBe('object');
expect(val).not.toBeNull();
@ -614,20 +614,27 @@ describe('createDeepLazyProxy', () => {
expect(Object.keys(p)).toEqual(['name']);
});
it('host data with an own __proto__ key is cached as an own property, not a prototype', () => {
const protoValue = { polluted: true };
mocks.getValueAtPath.mockImplementation((path: string[]) => {
const key = path[path.length - 1];
if (key === 'name') return 'Alice';
if (key === '__proto__') return protoValue;
return undefined;
});
const p = proxy(['user'], ['name', '__proto__']);
p.name = 'Zed'; // triggers materialization over all keys
expect(Object.keys(p)).toEqual(['name', '__proto__']);
expect(p.polluted).toBeUndefined();
expect(p['__proto__']).toBe(protoValue);
});
it.each(['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf'])(
'preserves the own field %s before and after a write',
(field) => {
const fieldValue = { label: 'Field value' };
mocks.getValueAtPath.mockImplementation((path: string[]) => {
const key = path[path.length - 1];
if (key === 'name') return 'Alice';
if (key === field) return fieldValue;
return undefined;
});
const p = proxy(['user'], ['name', field]);
expect(Object.fromEntries(Object.entries(p))).toEqual({
name: 'Alice',
[field]: fieldValue,
});
p.name = 'Zed'; // triggers materialization over all keys
expect(Object.keys(p)).toEqual(['name', field]);
expect(p.label).toBeUndefined();
expect(p[field]).toBe(fieldValue);
},
);
it('write fetches each uncached key exactly once; later access stays local', () => {
const p = writableObjectProxy();

View File

@ -1,3 +1,5 @@
import { isKeyOf } from './utils';
// ============================================================================
// Deep Lazy Proxy System
// ============================================================================
@ -312,7 +314,10 @@ export function createDeepLazyProxy(
return arrayLength;
}
if (prop in targetObj) {
if (
isKeyOf(targetObj, prop) ||
(prop in targetObj && (isArray || !resolveObjectKeys().includes(prop)))
) {
return targetObj[prop];
}

View File

@ -1231,6 +1231,7 @@ describe('WorkflowExecuteAdditionalData', () => {
name: 'Test Workflow',
active: true,
activeVersionId: 'version-456',
versionId: 'draft-version',
nodes: currentNodes,
connections: currentConnections,
activeVersion: mock({
@ -1249,6 +1250,7 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(result.nodes).toEqual(activeVersionNodes);
expect(result.connections).toEqual(activeVersionConnections);
expect(result.versionId).toBe('version-456');
expect(workflowRepository.get).toHaveBeenCalledWith(
{ id: 'workflow-123' },
{ relations: ['activeVersion', 'tags'] },
@ -1381,8 +1383,13 @@ describe('WorkflowExecuteAdditionalData', () => {
];
const mappingConnections = { 'Mapping Node': {} };
workflowPublishedDataService.getPublishedWorkflowData.mockResolvedValue({
workflow: mock<WorkflowEntity>({ id: 'workflow-123', name: 'Test Workflow' }),
workflow: mock<WorkflowEntity>({
id: 'workflow-123',
name: 'Test Workflow',
versionId: 'draft-version',
}),
publishedVersion: mock<WorkflowHistory>({
versionId: 'published-version',
nodes: mappingNodes,
connections: mappingConnections,
}),
@ -1397,6 +1404,7 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(workflowRepository.get).not.toHaveBeenCalled();
expect(result.nodes).toEqual(mappingNodes);
expect(result.connections).toEqual(mappingConnections);
expect(result.versionId).toBe('published-version');
});
it('throws when there is no published version (flag on)', async () => {

View File

@ -682,7 +682,17 @@ describe('AgentPublishService', () => {
versionId: 'published-v1',
schema: {
...schema,
tools: [{ type: 'custom', id: 'tool', enabled: false, requireApproval: true }],
tools: [
{ type: 'custom', id: 'tool', enabled: false, requireApproval: true },
{
type: 'workflow',
workflow: 'Test workflow',
inputs: {
constructor: { mode: 'ai' as const, description: 'Use the label.' },
hasOwnProperty: { mode: 'fixed' as const, value: '=literal' },
},
},
],
skills: [{ type: 'skill', id: 'skill', enabled: false }],
subAgents: { agents: [{ agentId: 'agent-2', enabled: false, useWhen: 'Review notes' }] },
tasks: [{ type: 'task', id: 'task-1', enabled: false }],

View File

@ -6,6 +6,7 @@ import { mock } from 'vitest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
import type { EphemeralNodeExecutor } from '@/node-execution/ephemeral-node-executor';
import type { WorkflowRunner } from '@/workflow-runner';
import {
@ -85,6 +86,7 @@ function makeContext(foundWorkflow: WorkflowEntity | null): WorkflowToolContext
return {
workflowLoader,
executor: mock<EphemeralNodeExecutor>(),
workflowRunner,
subworkflowPolicyChecker: mock<SubworkflowPolicyChecker>(),
activeExecutions,
@ -316,6 +318,85 @@ describe('resolveWorkflowTool() — metadata attachment', () => {
expect.anything(),
);
});
it('resolves configured inputs for new calls and preserves them on continuation', async () => {
const trigger = makeExecuteWorkflowTriggerNode({
parameters: {
inputSource: 'jsonExample',
jsonExample: JSON.stringify({ sides: 6, count: 1, label: '' }),
},
});
const context = makeContext(makeWorkflow({}, trigger));
const executor = mock<EphemeralNodeExecutor>();
executor.evaluateExpressions
.mockResolvedValueOnce({ count: 2 })
.mockResolvedValueOnce({ count: 3 })
.mockResolvedValueOnce({ count: 'invalid' })
.mockRejectedValueOnce(new Error('Cannot resolve input "count"'));
context.executor = executor;
const run = vi.fn().mockResolvedValue('exec-1');
context.workflowRunner.run = run;
context.activeExecutions.has = vi.fn().mockReturnValue(false);
Container.set(ExecutionPersistence, {
findSingleExecution: vi.fn().mockResolvedValue({
status: 'success',
data: { resultData: { runData: {} } },
}),
} as unknown as ExecutionPersistence);
const tool = await resolveWorkflowTool(
{
type: 'workflow',
workflow: 'My Test Workflow',
inputs: {
count: { mode: 'expression', value: '={{ $json.sides / 3 }}' },
hasOwnProperty: { mode: 'expression' as const, value: '={{ $json.removed }}' },
label: { mode: 'fixed', value: '={{ literal }}' },
},
},
context,
);
for (const [sides, count] of [
[6, 2],
[9, 3],
]) {
await tool.handler?.({ sides, count: 99, label: 'model value' }, {});
expect(run).toHaveBeenLastCalledWith(
expect.objectContaining({
pinData: { [TRIGGER_NAME]: [{ json: { sides, count, label: '={{ literal }}' } }] },
}),
undefined,
undefined,
undefined,
expect.anything(),
);
}
await expect(tool.handler?.({ sides: 6 }, {})).rejects.toThrow('count');
await expect(tool.handler?.({ sides: 6 }, {})).rejects.toThrow('Cannot resolve input "count"');
expect(run).toHaveBeenCalledTimes(2);
expect(executor.evaluateExpressions).toHaveBeenLastCalledWith(
expect.objectContaining({ projectId: 'project-1' }),
{ count: '={{ $json.sides / 3 }}' },
[{ json: { sides: 6 } }],
);
await expect(
tool.handler?.(
{},
{
continuation: { executionId: 'exec-1' },
suspend: vi.fn(),
resumeData: undefined,
},
),
).resolves.toMatchObject({
executionId: 'exec-1',
status: 'success',
});
expect(executor.evaluateExpressions).toHaveBeenCalledTimes(4);
expect(run).toHaveBeenCalledTimes(2);
});
});
describe('workflow tool compatibility', () => {

View File

@ -53,7 +53,8 @@ export class AgentDefinitionService {
ctx: OperationContext = {},
): Promise<boolean> {
return await this.transactionRunner.run(ctx, async (txCtx) => {
agent.schema = deepCopy(definition.schema);
// Preserve workflow input names in the JSON configuration.
agent.schema = structuredClone(definition.schema);
agent.tools = deepCopy(definition.tools);
agent.skills = deepCopy(definition.skills);
if (agent.schema) agent.name = agent.schema.name;

View File

@ -836,6 +836,7 @@ export class AgentRuntimeReconstructionService {
);
const context = {
workflowLoader: Container.get(WorkflowToolWorkflowLoader),
executor: this.ephemeralNodeExecutor,
workflowRunner: await getWorkflowRunner(),
subworkflowPolicyChecker: Container.get(SubworkflowPolicyChecker),
activeExecutions: this.activeExecutions,

View File

@ -1,12 +1,13 @@
import type { INode } from 'n8n-workflow';
import { z } from 'zod';
import { zodSchemaToJsonSchema } from '@n8n/ai-utilities/json-schema';
import {
getFixedWorkflowToolInputs,
inferInputSchema,
listWorkflowInputFields,
mergeWorkflowToolInput,
omitFixedFieldsFromSchema,
buildWorkflowToolInputSchema,
} from '../workflow-tool-factory';
function makeExecuteWorkflowTrigger(parameters: INode['parameters']): INode {
@ -142,43 +143,55 @@ describe('inferInputSchema — executeWorkflow', () => {
});
});
describe('fixed workflow tool inputs', () => {
it('extracts fixed values and omits them from the LLM schema', () => {
describe('configured workflow tool inputs', () => {
it('advertises AI inputs and merges configured values', () => {
const schema = z.object({
chatId: z.string().optional(),
shoppingListId: z.string().optional(),
botName: z.string().optional(),
count: z.number().optional(),
});
const inputs = {
shoppingListId: { mode: 'fixed' as const, value: 'OySx3QNU0BcCs8yz' },
botName: { mode: 'fixed' as const, value: 'Jarvis' },
chatId: { mode: 'ai' as const },
['__proto__']: { mode: 'fixed' as const, value: { source: 'configured' } },
chatId: { mode: 'ai' as const, description: 'Input guidance' },
count: { mode: 'expression' as const, value: '={{ 2 }}' },
constructor: { mode: 'ai' as const, description: 'Removed input' },
};
expect(getFixedWorkflowToolInputs(inputs)).toEqual({
const fixedInputs = getFixedWorkflowToolInputs(inputs);
expect(fixedInputs).toEqual({
shoppingListId: 'OySx3QNU0BcCs8yz',
botName: 'Jarvis',
['__proto__']: { source: 'configured' },
});
expect(Object.getPrototypeOf(fixedInputs)).toBe(Object.prototype);
const llmSchema = omitFixedFieldsFromSchema(schema, inputs);
const llmSchema = buildWorkflowToolInputSchema(schema, inputs);
expect(Object.keys(llmSchema.shape)).toEqual(['chatId']);
expect(zodSchemaToJsonSchema(llmSchema)).toMatchObject({
properties: { chatId: { description: inputs.chatId.description } },
});
expect(
mergeWorkflowToolInput(
llmSchema.parse({ chatId: '42' }) as Record<string, unknown>,
inputs,
schema,
{ count: 2 },
),
).toEqual({
chatId: '42',
shoppingListId: 'OySx3QNU0BcCs8yz',
botName: 'Jarvis',
count: 2,
});
});
it('preserves catchall when omitting fixed keys from an open schema', () => {
const schema = z.object({}).catchall(z.unknown());
const inputs = { pinned: { mode: 'fixed' as const, value: 'yes' } };
const llmSchema = omitFixedFieldsFromSchema(schema, inputs);
const llmSchema = buildWorkflowToolInputSchema(schema, inputs);
expect(llmSchema.parse({ anything: 1 })).toEqual({ anything: 1 });
expect(mergeWorkflowToolInput({ anything: 1 }, inputs, schema)).toEqual({
anything: 1,

View File

@ -17,6 +17,7 @@ import { mock } from 'vitest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
import type { EphemeralNodeExecutor } from '@/node-execution/ephemeral-node-executor';
import { WebhookResponseRelay } from '@/scaling/webhook-response-relay';
import type { WorkflowRunner } from '@/workflow-runner';
@ -55,6 +56,7 @@ const workflow = {
function buildContext(run: ReturnType<typeof vi.fn>, extras: Partial<WorkflowToolContext> = {}) {
return {
workflowLoader: {} as never,
executor: mock<EphemeralNodeExecutor>(),
workflowRunner: { run } as unknown as WorkflowRunner,
subworkflowPolicyChecker: mock<SubworkflowPolicyChecker>(),
activeExecutions: { has: vi.fn().mockReturnValue(false) } as unknown as ActiveExecutions,

View File

@ -68,7 +68,9 @@ describe('WorkflowToolWorkflowLoader', () => {
const { service, workflowRepository } = makeService();
workflowRepository.findOneByAgentToolReference.mockResolvedValue(
makeWorkflow({
versionId: 'draft-version',
activeVersion: {
versionId: 'published-version',
nodes: [{ id: 'published-node' }],
connections: { Published: {} },
},
@ -85,6 +87,7 @@ describe('WorkflowToolWorkflowLoader', () => {
{ withActiveVersion: true },
);
expect(workflow).toMatchObject({
versionId: 'published-version',
nodes: [{ id: 'published-node' }],
connections: { Published: {} },
});
@ -113,10 +116,13 @@ describe('WorkflowToolWorkflowLoader', () => {
const { service, workflowRepository, workflowPublishedDataService } = makeService({
useWorkflowPublicationService: true,
});
workflowRepository.findOneByAgentToolReference.mockResolvedValue(makeWorkflow());
workflowRepository.findOneByAgentToolReference.mockResolvedValue(
makeWorkflow({ versionId: 'draft-version' }),
);
workflowPublishedDataService.getPublishedWorkflowData.mockResolvedValue({
workflow: makeWorkflow(),
publishedVersion: {
versionId: 'published-version',
nodes: [{ id: 'published-node' }],
connections: { Published: {} },
},
@ -129,6 +135,9 @@ describe('WorkflowToolWorkflowLoader', () => {
expect(workflowPublishedDataService.getPublishedWorkflowData).toHaveBeenCalledWith(
'workflow-1',
);
expect(workflow).toMatchObject({ nodes: [{ id: 'published-node' }] });
expect(workflow).toMatchObject({
versionId: 'published-version',
nodes: [{ id: 'published-node' }],
});
});
});

View File

@ -31,6 +31,7 @@ import {
createRunExecutionData,
isTerminalExecutionStatus,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
TimeoutExecutionCancelledError,
isIndefiniteWait,
} from 'n8n-workflow';
@ -40,6 +41,7 @@ import { z } from 'zod';
import type { ActiveExecutions } from '@/active-executions';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
import type { EphemeralNodeExecutor } from '@/node-execution/ephemeral-node-executor';
import { WebhookResponseRelay } from '@/scaling/webhook-response-relay';
import type { WorkflowRunner } from '@/workflow-runner';
@ -151,6 +153,7 @@ export type WorkflowToolExecutionMode = Extract<WorkflowExecuteMode, 'manual' |
export interface WorkflowToolContext {
workflowLoader: WorkflowToolWorkflowLoader;
executor: EphemeralNodeExecutor;
workflowRunner: WorkflowRunner;
subworkflowPolicyChecker: SubworkflowPolicyChecker;
activeExecutions: ActiveExecutions;
@ -362,29 +365,29 @@ export function getFixedWorkflowToolInputs(
inputs: WorkflowToolInputsConfig | undefined,
): Record<string, unknown> {
if (!inputs) return {};
const fixed: Record<string, unknown> = {};
const fixed: Array<[string, unknown]> = [];
for (const [name, binding] of Object.entries(inputs)) {
if (binding.mode === 'fixed') {
fixed[name] = binding.value;
fixed.push([name, binding.value]);
}
}
return fixed;
return Object.fromEntries(fixed);
}
/**
* Drop fixed-bound keys from the LLM-facing schema so the model is not asked
* for values the user already configured.
*/
export function omitFixedFieldsFromSchema(
/** Advertise AI inputs and their guidance. Configured values stay out of the model schema. */
export function buildWorkflowToolInputSchema(
schema: z.ZodObject<z.ZodRawShape>,
inputs: WorkflowToolInputsConfig | undefined,
): z.ZodObject<z.ZodRawShape> {
const fixedKeys = Object.keys(getFixedWorkflowToolInputs(inputs));
if (fixedKeys.length === 0) return schema;
if (!inputs) return schema;
const shape = { ...schema.shape };
for (const key of fixedKeys) {
delete shape[key];
for (const [name, binding] of Object.entries(inputs)) {
if (binding.mode !== 'ai') {
delete shape[name];
} else if (Object.hasOwn(shape, name) && binding.description?.trim()) {
shape[name] = shape[name].describe(binding.description.trim());
}
}
const catchall = schema._def.catchall as z.ZodTypeAny | undefined;
@ -394,25 +397,57 @@ export function omitFixedFieldsFromSchema(
return z.object(shape);
}
/**
* Merge LLM-supplied args with fixed tool-config values (fixed wins), then
* parse the result against the full declared schema so fixed values are
* coerced to their field's declared type (e.g. numeric IDs stored as strings
* are converted to numbers for number fields). Without this, fixed bindings
* bypass the schema and reach the sub-workflow with the wrong runtime type.
*/
/** Merge configured values over model arguments. Validate and coerce them to the trigger types. */
export function mergeWorkflowToolInput(
llmInput: Record<string, unknown>,
inputs: WorkflowToolInputsConfig | undefined,
fullSchema: z.ZodObject<z.ZodRawShape>,
expressionValues: Record<string, unknown> = {},
): Record<string, unknown> {
const merged: Record<string, unknown> = {
...llmInput,
...getFixedWorkflowToolInputs(inputs),
...expressionValues,
};
return fullSchema.parse(merged);
}
async function resolveWorkflowToolInput(
input: Record<string, unknown>,
inputs: WorkflowToolInputsConfig | undefined,
fullSchema: z.ZodObject<z.ZodRawShape>,
context: WorkflowToolContext,
toolName: string,
): Promise<Record<string, unknown>> {
const modelInput: IDataObject = buildWorkflowToolInputSchema(fullSchema, inputs).parse(input);
const expressions: Record<string, string> = {};
const acceptsExtraInputs = !(fullSchema._def.catchall instanceof z.ZodNever);
for (const [name, binding] of Object.entries(inputs ?? {})) {
if (
binding.mode === 'expression' &&
(Object.hasOwn(fullSchema.shape, name) || acceptsExtraInputs)
) {
expressions[name] = binding.value;
}
}
if (Object.keys(expressions).length === 0) {
return mergeWorkflowToolInput(modelInput, inputs, fullSchema);
}
const resolved = await context.executor.evaluateExpressions(
{
projectId: context.projectId,
nodeType: WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
nodeTypeVersion: 2.2,
nodeParameters: {},
nodeName: toolName,
},
expressions,
[{ json: modelInput }],
);
return mergeWorkflowToolInput(modelInput, inputs, fullSchema, resolved);
}
// ---------------------------------------------------------------------------
// 4. executeWorkflow
// ---------------------------------------------------------------------------
@ -1006,7 +1041,7 @@ async function buildWorkflowTool(
return assembleWorkflowTool(descriptor, context, {
reference: { workflowId: workflow.id, workflowName: workflow.name },
inputSchema: omitFixedFieldsFromSchema(fullInputSchema, descriptor.inputs),
inputSchema: buildWorkflowToolInputSchema(fullInputSchema, descriptor.inputs),
triggerType,
});
}
@ -1055,7 +1090,7 @@ function assembleWorkflowTool(
// A continuation means this workflow already ran on without us, so re-running
// it would be wrong. Skip the reload too: a settled run's output should still
// come back even if the workflow was archived since. The input is not parsed
// again either — fixed tool inputs were merged in on the original call.
// again either — configured inputs were resolved on the original call.
const pending = WAIT_CONTINUATION_SCHEMA.safeParse(ctx.continuation);
let current: Awaited<ReturnType<typeof loadCurrentWorkflow>> | undefined;
let result: WorkflowToolExecutionResult;
@ -1065,11 +1100,12 @@ function assembleWorkflowTool(
} else {
current = await loadCurrentWorkflow(context, reference);
const currentFullSchema = inferInputSchema(current.triggerNode, current.triggerType);
const currentSchema = omitFixedFieldsFromSchema(currentFullSchema, toolInputs);
const parsedInput = mergeWorkflowToolInput(
currentSchema.parse(input),
const parsedInput = await resolveWorkflowToolInput(
input,
toolInputs,
currentFullSchema,
context,
toolName,
);
result = await executeWorkflow(
current.workflow,

View File

@ -49,7 +49,11 @@ export class WorkflowToolWorkflowLoader {
`Workflow "${workflow.name}" is not published. Publish it so the published agent can use it.`,
);
}
Object.assign(workflow, { nodes: published.nodes, connections: published.connections });
Object.assign(workflow, {
versionId: published.versionId,
nodes: published.nodes,
connections: published.connections,
});
}
return Object.assign(workflow, { pinData: undefined });

View File

@ -947,7 +947,7 @@ describe('EphemeralNodeExecutor', () => {
});
});
it('resolves expressions when invoking a supplyData tool with the VM engine', async () => {
it('resolves expressions in standalone tool contexts with the VM engine', async () => {
await Expression.initExpressionEngine({
engine: 'vm',
bridgeTimeout: 1000,
@ -993,6 +993,38 @@ describe('EphemeralNodeExecutor', () => {
status: 'success',
data: [{ json: { response: 2 } }],
});
mockGetBase.mockResolvedValue({ variables: { increment: '3' } });
const tool = {
nodeType: '@n8n/n8n-nodes-langchain.toolWorkflow',
nodeTypeVersion: 2.2,
nodeParameters: {},
projectId: 'p-1',
};
await expect(
executor.evaluateExpressions(
tool,
{
count: '={{ $json.count + Number($vars.increment) }}',
options: '={{ { enabled: true, items: [1, 2] } }}',
date: '={{ $now.toISODate() }}',
},
[{ json: { count: 4 } }],
),
).resolves.toEqual({
count: 7,
options: { enabled: true, items: [1, 2] },
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
});
await expect(
executor.evaluateExpressions(tool, { broken: '={{ 1 + }}' }, []),
).rejects.toThrow('Cannot resolve input "broken"');
// A failed expression must release the single isolate for the next call.
await expect(
executor.evaluateExpressions(tool, { count: '={{ $json.count }}' }, [
{ json: { count: 9 } },
]),
).resolves.toEqual({ count: 9 });
} finally {
await Expression.disposeExpressionEngine();
}

View File

@ -384,6 +384,42 @@ export class EphemeralNodeExecutor {
}
}
/** Resolve input expressions in the same context as a standalone node tool. */
async evaluateExpressions(
tool: EphemeralWorkflowToolLike,
expressions: Record<string, string>,
inputItems: INodeExecutionData[],
): Promise<Record<string, unknown>> {
const parts = await this.buildEphemeralContextParts(tool, inputItems);
const context = new ExecuteContext(
parts.workflow,
parts.node,
parts.additionalData,
parts.mode,
parts.runExecutionData,
0,
inputItems,
parts.inputData,
parts.executeData,
[],
);
return await withExpressionIsolate(parts.workflow, async () => {
const resolved: Record<string, unknown> = {};
for (const [name, expression] of Object.entries(expressions)) {
try {
// evaluateExpression adds the leading '=' itself.
resolved[name] = context.evaluateExpression(expression.slice(1));
} catch (error) {
throw new UserError(`Cannot resolve input "${name}": ${getErrorMessage(error)}`, {
cause: error,
});
}
}
return resolved;
});
}
async executeInline(request: InlineNodeExecutionRequest): Promise<NodeExecutionResult> {
// Validation failures (unknown node type, trigger nodes, blacklisted
// operations like send-and-wait) need to surface to the agent as a

View File

@ -212,6 +212,7 @@ export async function getPublishedWorkflowData(
}
return {
...publishedData.workflow,
versionId: publishedData.publishedVersion.versionId,
nodes: publishedData.publishedVersion.nodes,
connections: publishedData.publishedVersion.connections,
};
@ -232,6 +233,7 @@ export async function getPublishedWorkflowData(
if (workflowData && 'activeVersion' in workflowData && workflowData.activeVersion) {
return {
...workflowData,
versionId: workflowData.activeVersion.versionId,
nodes: workflowData.activeVersion.nodes,
connections: workflowData.activeVersion.connections,
};

View File

@ -2797,7 +2797,8 @@
"parameterInputList.callout.dismiss.confirm.confirmButtonText": "Confirm",
"parameterInputList.callout.dismiss.confirm.cancelButtonText": "Cancel",
"parameterInputList.autoRequiresChatTriggerDescription": "Available when connected to a Chat Trigger node.",
"parameterOverride.overridePanelText": "Defined automatically by the <b>model</b>",
"parameterOverride.overridePanelText": "Defined by agent",
"parameterOverride.editValue": "Edit value",
"parameterOverride.applyOverrideButtonTooltip": "Let the model define this parameter",
"parameterOverride.descriptionTooltip": "Explain to the LLM how it should generate this value, a good, specific description would allow LLMs to produce expected results much more often",
"personalizationModal.businessOwner": "Business Owner",
@ -8303,15 +8304,14 @@
"agents.toolConfig.workflow.target.idNotFound": "No workflow with this ID can be used here. It has to be in this project and start with a '{trigger}' trigger.",
"agents.toolConfig.workflow.target.open": "Open workflow in a new tab",
"agents.toolConfig.workflow.inputs": "Workflow inputs",
"agents.toolConfig.workflow.inputs.hint": "The agent fills in these fields when it calls the workflow. Set a fixed value for any field the agent should not choose.",
"agents.toolConfig.workflow.inputs.mode.ai": "Determined by agent",
"agents.toolConfig.workflow.inputs.mode.fixed": "Fixed value",
"agents.toolConfig.workflow.inputs.value.placeholder": "Enter a fixed value",
"agents.toolConfig.workflow.inputs.value.placeholder.string": "Enter text",
"agents.toolConfig.workflow.inputs.value.placeholder.number": "Enter a number",
"agents.toolConfig.workflow.inputs.value.placeholder.boolean": "true or false",
"agents.toolConfig.workflow.inputs.value.placeholder.array": "Enter a JSON array, e.g. [\"a\", \"b\"]",
"agents.toolConfig.workflow.inputs.value.placeholder.object": "Enter a JSON object, e.g. {'{'}\"key\": \"value\"{'}'}",
"agents.toolConfig.workflow.advanced": "Advanced",
"agents.toolConfig.workflow.inputs.description": "Description (optional)",
"agents.toolConfig.workflow.inputs.description.placeholder": "Tell the agent what value to use",
"agents.toolConfig.workflow.inputs.empty": "This workflow has no defined inputs. Define inputs in its trigger to configure them here.",
"agents.toolConfig.workflow.inputs.loading": "Loading workflow inputs…",
"agents.toolConfig.workflow.inputs.loadError": "Couldn't load workflow inputs. Try again.",
"agents.toolConfig.workflow.inputs.expression.empty": "Enter an expression",
"agents.toolConfig.workflow.inputs.value.invalid": "Enter a valid {type} value",
"agents.toolConfig.validation.fixFields": "Fix the highlighted fields before saving.",
"agents.toolConfig.approval.label": "Require approval",
"agents.toolConfig.approval.hint": "Pause before this tool runs until a human approves it.",

View File

@ -34,6 +34,11 @@ export const ExecutionDataStoreKey: InjectionKey<
export const CanvasRenderDataKey: InjectionKey<Ref<CanvasRenderData>> = Symbol('CanvasRenderData');
/** Keep setup hints compact while retaining their full text on the field. */
export const CompactParameterHintsKey: InjectionKey<boolean> = Symbol('CompactParameterHints');
/** Lets modal hosts coordinate focus and portals for expanded parameter editors. */
export const ParameterInputModalContextKey: InjectionKey<{
openDialogs: Ref<Set<string>>;
appendTo: string;
}> = Symbol('ParameterInputModalContext');
/**
* Opts resource-locator dropdowns into teleporting to `<body>`. Defaults to
* `false` (stay in the local stacking context, e.g. inside the NDV dialog).

View File

@ -2586,6 +2586,15 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
});
it('refreshes runnable state from the backend after saving manual config edits', async () => {
const workflowTool: AgentJsonToolRef = {
type: 'workflow',
workflow: 'Test workflow',
inputs: {
constructor: { mode: 'ai' as const, description: 'Use the label.' },
hasOwnProperty: { mode: 'fixed' as const, value: '=literal' },
},
};
intendedConfig!.tools = [workflowTool];
getAgentMock
.mockResolvedValueOnce(makeAgentResponse({ isRunnable: false }))
.mockResolvedValueOnce(makeAgentResponse({ isRunnable: true, versionId: 'v2' }));
@ -2608,7 +2617,12 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
await vm.flushAutosave();
await nextTick();
expect(updateConfigMock).toHaveBeenCalled();
expect(updateConfigMock).toHaveBeenCalledWith(
'p1',
'a1',
expect.objectContaining({ tools: [workflowTool] }),
'hash-1',
);
expect(getAgentMock).toHaveBeenLastCalledWith({ baseUrl: 'http://localhost:5678' }, 'p1', 'a1');
expect(vm.isBuilt).toBe(true);
});

View File

@ -1,5 +1,8 @@
import { N8nDialog } from '@n8n/design-system';
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, h, inject, Teleport } from 'vue';
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
import AgentModal from '../components/modals/AgentModal.vue';
import AgentModalMultiStep from '../components/modals/AgentModalMultiStep.vue';
@ -185,6 +188,60 @@ describe('AgentModal', () => {
expect(wrapper.emitted('update:open')).toBeUndefined();
});
it('releases and restores focus and dismissal for a nested parameter editor', async () => {
const editor = defineComponent({
setup() {
const context = inject(ParameterInputModalContextKey)!;
return () => [
h(
'button',
{
'data-test-id': 'open-editor',
onClick: () => context.openDialogs.value.add('expression'),
},
'Open editor',
),
h(
Teleport,
{ to: document.body },
h('input', {
'data-test-id': 'parameter-editor-input',
onKeydown: () => context.openDialogs.value.delete('expression'),
}),
),
];
},
});
const wrapper = mount(AgentModal, {
props: { open: true, title: 'Tool settings' },
slots: { default: () => h(editor) },
});
const dialog = wrapper.get('[role="dialog"]');
await wrapper.get('[data-test-id="open-editor"]').trigger('click');
expect(dialog.attributes('data-trap-focus')).toBe('false');
expect(dialog.attributes('data-disable-outside-pointer-events')).toBe('false');
await wrapper.get('[data-test-id="dialog-escape"]').trigger('click');
await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click');
expect(dialog.attributes('data-escape-prevented')).toBe('true');
expect(wrapper.emitted('update:open')).toBeUndefined();
const escape = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
document.querySelector('[data-test-id="parameter-editor-input"]')!.dispatchEvent(escape);
wrapper.getComponent(N8nDialog).vm.$emit('escapeKeyDown', escape);
await flushPromises();
expect(escape.defaultPrevented).toBe(true);
expect(wrapper.emitted('update:open')).toBeUndefined();
expect(dialog.attributes('data-trap-focus')).toBe('true');
expect(dialog.attributes('data-disable-outside-pointer-events')).toBe('true');
await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click');
expect(wrapper.emitted('update:open')).toEqual([[false]]);
wrapper.unmount();
});
it('emits Back and Close when the modal is idle', async () => {
const wrapper = mountModal({ showBack: true });

View File

@ -2,17 +2,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { fireEvent, waitFor } from '@testing-library/vue';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue';
import { EditorView } from '@codemirror/view';
import { SUPPORTED_WORKFLOW_TOOL_TRIGGERS } from '@n8n/api-types';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import WorkflowToolConfigContent from '../components/WorkflowToolConfigContent.vue';
import WorkflowToolInputs from '../components/WorkflowToolInputs.vue';
import AgentToolConfigForm from '../components/AgentToolConfigForm.vue';
import type { WorkflowToolRef } from '../types';
vi.mock('vue-router', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
useRoute: () => ({ params: {}, query: {}, meta: {} }),
useRouter: () => ({
resolve: ({ params }: { params: { workflowId: string } }) => ({
href: `/workflow/${params.workflowId}`,
@ -21,11 +26,11 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
vi.mock('@n8n/i18n', () => {
const i18n = {
baseText: (key: string) => key,
};
return { useI18n: () => i18n, i18n, i18nInstance: { install: vi.fn() } };
vi.mock('@n8n/i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('@n8n/i18n')>();
const i18n = Object.create(actual.i18n) as typeof actual.i18n;
i18n.baseText = (key: string) => key;
return { ...actual, useI18n: () => i18n, i18n };
});
// Element Plus' select is unusable in jsdom, so only the select/option pair is
@ -86,6 +91,27 @@ function workflow(
const renderComponent = createComponentRenderer(WorkflowToolConfigContent);
function renderForm(initialRef: WorkflowToolRef) {
const form = ref<InstanceType<typeof AgentToolConfigForm>>();
const onConfirm = vi.fn();
const render = createComponentRenderer(
defineComponent({
setup: () => () =>
h('div', [
h(AgentToolConfigForm, { ref: form, data: { toolRef: initialRef, onConfirm } }),
h('button', { onClick: () => form.value?.confirm() }, 'Save'),
]),
}),
);
return { ...render(), onConfirm, form };
}
function editExpression(field: HTMLElement, expression: string) {
const content = field.querySelector<HTMLElement>('.cm-content')!;
const editor = EditorView.findFromDOM(content)!;
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: expression } });
}
function createRef(overrides: Partial<WorkflowToolRef> = {}): WorkflowToolRef {
return {
type: 'workflow',
@ -382,9 +408,11 @@ describe('WorkflowToolConfigContent', () => {
]),
]);
const { findByTestId } = renderComponent({ props: { initialRef: createRef() } });
const { findByTestId, getByTestId } = renderComponent({ props: { initialRef: createRef() } });
expect(await findByTestId('agent-workflow-tool-inputs')).toBeTruthy();
expect(await findByTestId('agent-workflow-tool-inputs')).not.toBeVisible();
await fireEvent.click(getByTestId('agent-workflow-tool-advanced'));
expect(getByTestId('agent-workflow-tool-inputs')).toBeVisible();
expect(await findByTestId('agent-workflow-tool-input-chatId')).toBeTruthy();
expect(await findByTestId('agent-workflow-tool-input-botName')).toBeTruthy();
});
@ -405,12 +433,160 @@ describe('WorkflowToolConfigContent', () => {
},
});
await findByTestId('agent-workflow-tool-input-value-botName');
expect(
(getByTestId('agent-workflow-tool-input-value-botName') as HTMLInputElement).value,
).toBe('Jarvis');
const field = await findByTestId('agent-workflow-tool-input-botName');
await fireEvent.click(getByTestId('agent-workflow-tool-advanced'));
expect(within(field).getByRole('textbox')).toHaveValue('Jarvis');
});
it.each(['constructor', 'toString', 'hasOwnProperty', '__proto__'])(
'configures and reopens an input named %s',
async (name) => {
const inputs = shallowRef<NonNullable<WorkflowToolRef['inputs']>>({});
const inputType = ref('string');
const render = createComponentRenderer(
defineComponent({
setup: () => () =>
h(WorkflowToolInputs, {
fields: [{ name, type: inputType.value }],
toolName: 'Notify Sales',
submitted: false,
modelValue: inputs.value,
'onUpdate:modelValue': (updated: NonNullable<WorkflowToolRef['inputs']>) => {
inputs.value = updated;
},
}),
}),
);
const view = render();
const field = view.getByTestId(`agent-workflow-tool-input-${name}`);
await fireEvent.click(
within(field).getByRole('button', {
name: 'parameterOverride.editValue',
}),
);
expect(within(field).getByRole('textbox')).toHaveValue('');
expect(inputs.value).toEqual({ [name]: { mode: 'fixed', value: '' } });
await fireEvent.update(within(field).getByRole('textbox'), 'Literal value');
expect(inputs.value).toEqual({ [name]: { mode: 'fixed', value: 'Literal value' } });
view.unmount();
const reopened = render();
const savedField = reopened.getByTestId(`agent-workflow-tool-input-${name}`);
await waitFor(() =>
expect(within(savedField).getByRole('textbox')).toHaveValue('Literal value'),
);
await fireEvent.click(
within(savedField).getByRole('button', {
name: 'parameterOverride.applyOverrideButtonTooltip',
}),
);
expect(inputs.value).toEqual({});
await fireEvent.update(within(savedField).getByRole('textbox'), 'Input guidance');
expect(inputs.value).toEqual({ [name]: { mode: 'ai', description: 'Input guidance' } });
inputType.value = 'object';
await nextTick();
await fireEvent.click(
within(savedField).getByRole('button', { name: 'parameterOverride.editValue' }),
);
const rawValue = '{ "answer": 42 }';
editExpression(savedField, rawValue);
await waitFor(() =>
expect(inputs.value).toEqual({ [name]: { mode: 'fixed', value: { answer: 42 } } }),
);
await nextTick();
const content = savedField.querySelector<HTMLElement>('.cm-content')!;
expect(EditorView.findFromDOM(content)?.state.doc.toString()).toBe(rawValue);
},
);
it('saves and reopens all binding modes without changing the original reference', async () => {
setProjectWorkflows([
workflowWithInputs('Notify Sales', [
{ name: 'query', type: 'string' },
{ name: 'label', type: 'string' },
{ name: 'count', type: 'number' },
]),
]);
const initialRef = createRef();
const view = renderForm(initialRef);
await view.findByTestId('agent-workflow-tool-inputs');
await fireEvent.click(view.getByTestId('agent-workflow-tool-advanced'));
await fireEvent.update(
within(view.getByTestId('agent-workflow-tool-input-query')).getByRole('textbox'),
'Input guidance',
);
const label = within(view.getByTestId('agent-workflow-tool-input-label'));
await fireEvent.click(label.getByRole('button', { name: 'parameterOverride.editValue' }));
await fireEvent.update(label.getByRole('textbox'), '={{ literal }}');
const count = view.getByTestId('agent-workflow-tool-input-count');
await fireEvent.click(
within(count).getByRole('button', { name: 'parameterOverride.editValue' }),
);
await fireEvent.click(within(count).getByText('parameterInput.expression'));
editExpression(count, '{{ $json.query.length }}');
await waitFor(() => expect(view.form.value?.canSave).toBe(true));
await fireEvent.click(view.getByTestId('agent-workflow-tool-advanced'));
await fireEvent.click(view.getByRole('button', { name: 'Save' }));
expect(view.onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
inputs: {
query: { mode: 'ai', description: 'Input guidance' },
label: { mode: 'fixed', value: '={{ literal }}' },
count: { mode: 'expression', value: '={{ $json.query.length }}' },
},
}),
);
expect(initialRef.inputs).toBeUndefined();
const saved = view.onConfirm.mock.calls[0][0] as WorkflowToolRef;
view.unmount();
const reopened = renderForm(saved);
await reopened.findByTestId('agent-workflow-tool-inputs');
await fireEvent.click(reopened.getByTestId('agent-workflow-tool-advanced'));
expect(
within(reopened.getByTestId('agent-workflow-tool-input-query')).getByRole('textbox'),
).toHaveValue('Input guidance');
expect(
within(reopened.getByTestId('agent-workflow-tool-input-label')).getByRole('textbox'),
).toHaveValue('={{ literal }}');
expect(reopened.getByTestId('agent-workflow-tool-input-count')).toHaveTextContent(
'$json.query.length',
);
});
it.each([
{ mode: 'expression', value: '=' },
{ mode: 'fixed', value: 'not a number' },
] as const)(
'validates $mode inputs on Save and preserves them when loading fails',
async (binding) => {
const inputs: WorkflowToolRef['inputs'] = { count: binding };
setProjectWorkflows([
workflowWithInputs('Notify Sales', [{ name: 'count', type: 'number' }]),
]);
const view = renderForm(createRef({ inputs }));
await view.findByTestId('agent-workflow-tool-inputs');
for (let attempt = 0; attempt < 2; attempt++) {
await fireEvent.click(view.getByRole('button', { name: 'Save' }));
expect(view.onConfirm).not.toHaveBeenCalled();
expect(view.getByTestId('agent-workflow-tool-advanced')).toHaveAttribute(
'aria-expanded',
'true',
);
await fireEvent.click(view.getByTestId('agent-workflow-tool-advanced'));
}
view.unmount();
mockedStore(useWorkflowsListStore).searchWorkflows = vi
.fn()
.mockRejectedValue(new Error('offline'));
const failedLoad = renderForm(createRef({ inputs }));
await fireEvent.click(failedLoad.getByTestId('agent-workflow-tool-advanced'));
await failedLoad.findByText('agents.toolConfig.workflow.inputs.loadError');
await fireEvent.click(failedLoad.getByRole('button', { name: 'Save' }));
expect(failedLoad.onConfirm).toHaveBeenCalledWith(expect.objectContaining({ inputs }));
},
);
it('does not render input bindings for passthrough workflows', async () => {
setProjectWorkflows([
{

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { reactive } from 'vue';
import {
configToDoc,
getSlice,
@ -45,10 +46,21 @@ describe('agentSectionEditor.utils', () => {
});
describe('setSlice', () => {
it('does not mutate the input', () => {
const next = setSlice(cfg, 'name', 'y');
it('does not mutate the input or remove workflow input bindings', () => {
const tool: NonNullable<AgentJsonConfig['tools']>[number] = {
type: 'workflow',
workflow: 'Test workflow',
inputs: {
constructor: { mode: 'ai' as const, description: 'Use the label.' },
hasOwnProperty: { mode: 'fixed' as const, value: '=literal' },
},
};
const source = reactive({ ...cfg, tools: [tool] });
const next = setSlice(source, 'name', 'y');
expect(next.name).toBe('y');
expect(cfg.name).toBe('x');
expect(source.name).toBe('x');
expect(next.tools).toEqual([tool]);
expect(next.tools?.[0]).not.toBe(tool);
});
it('replaces nested object values', () => {
const next = setSlice(cfg, 'config.toolCallConcurrency', 8);

View File

@ -109,8 +109,10 @@ Do not add top padding or a top margin to a modal's first content wrapper. The
shell supplies that space. Use the flush body only for a full-bleed workspace.
The workspace must then own all of its edge spacing.
When a nested credential dialog is open, release the parent focus trap and
block parent dismissal. The nested dialog owns Escape until it closes.
When a nested credential or parameter editor dialog is open, release the parent
focus trap and block parent dismissal. The nested dialog owns Escape until it
closes. Render expanded parameter editors in the body portal above the Agent
modal.
### Title contract
@ -228,6 +230,15 @@ Add a section when an Agent-specific pattern applies to two or more Agent
surfaces. Keep implementation details with the owning pattern. Do not duplicate
global Design System guidance.
## Model-defined inputs
Use `ParameterInputFull` for workflow inputs and node tool parameters.
It owns the field label, Fixed/Expression controls, AI button, model chip,
and hover and focus behavior. Use its controlled input mode for workflow
bindings. Keep binding conversion and optional input guidance in the caller.
Hide the Edit value action in read-only forms. Keep input issues visible
beside the model label.
## Tool approvals
Use `N8nApprovalCard` for Preview tool approvals, including background child

View File

@ -97,7 +97,7 @@ const nodeContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | nul
const mcpContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
const workflowContentRef = ref<InstanceType<typeof AgentToolConfigWorkflowContent> | null>(null);
const isValid = ref(false);
const submitted = ref(false);
const submitCount = ref(0);
const approvalRequired = ref(false);
const mcpApproval = ref<AgentJsonMcpServerConfig['approval']>();
const mcpApprovalValid = ref(true);
@ -235,7 +235,7 @@ function withMcpApproval(server: AgentJsonMcpServerConfig): AgentJsonMcpServerCo
}
function confirm(): boolean {
submitted.value = true;
submitCount.value += 1;
if (!canSave.value) return false;
if (isCustomTool.value) {
@ -300,7 +300,7 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
<template>
<div :class="[$style.contentWrapper, isCustomTool && $style.codeContentWrapper]">
<N8nText
v-if="submitted && !canSave"
v-if="submitCount && !canSave"
size="small"
color="danger"
data-testid="agent-tool-config-validation-error"
@ -321,6 +321,7 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
v-if="workflowInitialRef"
ref="workflowContentRef"
:initial-ref="workflowInitialRef"
:submit-count="submitCount"
:project-id="data.projectId"
:show-approval-setting="showApprovalSetting"
:approval-required="approvalRequired"

View File

@ -10,6 +10,7 @@ const props = defineProps<{
projectId?: string;
showApprovalSetting?: boolean;
approvalRequired?: boolean;
submitCount?: number;
}>();
const emit = defineEmits<{
@ -64,6 +65,7 @@ defineExpose({
ref="contentRef"
:initial-ref="props.initialRef"
:project-id="props.projectId"
:submit-count="props.submitCount"
@update:valid="emit('update:valid', $event)"
@update:node-name="emit('update:node-name', $event)"
>

View File

@ -1,25 +1,11 @@
<script setup lang="ts">
/**
* Configure a workflow-type tool on an agent.
*
* Workflow tools have a very different shape from node tools — no node
* parameters, no credentials — so we render a small dedicated form instead
* of reusing `NodeToolSettingsContent`. The LLM-facing fields are:
* - workflowId (the target workflow's stable lookup key)
* - workflow (the target workflow's display name and legacy lookup key)
* - name (edited in the modal header's inline-text widget)
* - description (what the LLM reads to understand when to use the tool)
* - allOutputs (`true` returns every node output; `false` = last node only)
* - inputs (optional AI vs fixed bindings for Execute Workflow Trigger fields)
*
* The underlying workflow's runtime input schema is inferred by
* `WorkflowToolFactory.inferInputSchema` at invocation time; this form only
* lets the user pin fixed values so the LLM is not asked for them.
*/
import { computed, onMounted, ref, watch } from 'vue';
/** Configure a workflow tool without changing its trigger. */
import { computed, onMounted, ref, toRaw, watch } from 'vue';
import dateformat from 'dateformat';
import {
N8nButton,
N8nCallout,
N8nIcon,
N8nIconButton,
N8nInput,
N8nOption,
@ -28,23 +14,19 @@ import {
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import type { AgentJsonWorkflowToolInputField } from '@n8n/api-types';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { useAgentToolCatalog } from '../composables/useAgentToolCatalog';
import type { WorkflowToolRef } from '../types';
import {
formatWorkflowToolFixedValue,
listWorkflowToolInputFields,
parseWorkflowToolFixedValue,
} from '../utils/workflowToolInputFields';
import { listWorkflowToolInputFields } from '../utils/workflowToolInputFields';
import WorkflowToolInputs from './WorkflowToolInputs.vue';
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
const props = defineProps<{
initialRef: WorkflowToolRef;
projectId?: string;
submitCount?: number;
}>();
const emit = defineEmits<{
@ -64,17 +46,23 @@ const allOutputs = ref(props.initialRef.allOutputs ?? false);
const workflow = ref(props.initialRef.workflow ?? '');
const workflowId = ref<string | undefined>(props.initialRef.workflowId);
const inputs = ref<NonNullable<WorkflowToolRef['inputs']>>({
...(props.initialRef.inputs ?? {}),
...toRaw(props.initialRef.inputs ?? {}),
});
const isLoadingWorkflows = ref(true);
const workflowsLoaded = ref(false);
const inputsValid = ref(true);
const advancedExpanded = ref(false);
const mode = ref<'list' | 'id'>('list');
const enteredId = ref('');
const isIdUnresolvable = ref(false);
onMounted(async () => {
await loadWorkflows(props.projectId);
async function reloadWorkflows() {
isLoadingWorkflows.value = true;
workflowsLoaded.value = await loadWorkflows(props.projectId);
isLoadingWorkflows.value = false;
});
}
onMounted(reloadWorkflows);
watch(
() => props.initialRef,
@ -84,30 +72,13 @@ watch(
allOutputs.value = updated.allOutputs ?? false;
workflow.value = updated.workflow ?? '';
workflowId.value = updated.workflowId;
inputs.value = { ...(updated.inputs ?? {}) };
inputs.value = { ...toRaw(updated.inputs ?? {}) };
mode.value = 'list';
enteredId.value = '';
isIdUnresolvable.value = false;
},
);
// Validity gate: target, name and description are required — the description
// is what the LLM reads to decide when to invoke the tool, and executing
// without one fails. Only allOutputs is free to stay false.
watch(
[name, description, workflow],
([nameValue, descriptionValue, workflowValue]) => {
emit(
'update:valid',
nameValue.trim().length > 0 &&
descriptionValue.trim().length > 0 &&
workflowValue.trim().length > 0,
);
emit('update:node-name', nameValue);
},
{ immediate: true },
);
function matchesReference(candidate: { id: string; name: string }) {
return workflowId.value !== undefined
? candidate.id === workflowId.value
@ -130,6 +101,7 @@ const targetWorkflow = computed(() => {
/** Target is gone from the project entirely — deleted, moved, or inaccessible. */
const isMissing = computed(
() =>
workflowsLoaded.value &&
!isLoadingWorkflows.value &&
workflow.value.length > 0 &&
matchingProjectWorkflows.value.length === 0,
@ -138,6 +110,7 @@ const isMissing = computed(
/** Target still exists but is archived or holds a node that can't run as a tool. */
const isUnusable = computed(
() =>
workflowsLoaded.value &&
!isLoadingWorkflows.value &&
!isMissing.value &&
workflow.value.length > 0 &&
@ -152,6 +125,7 @@ const isAmbiguous = computed(
/** Target works in preview, but the published agent cannot call it until it is published. */
const isUnpublished = computed(
() =>
workflowsLoaded.value &&
!isLoadingWorkflows.value &&
!isUnusable.value &&
targetWorkflow.value?.activeVersionId === null,
@ -182,87 +156,34 @@ const selectedOptionId = computed(
const declaredInputFields = computed(() => listWorkflowToolInputFields(targetWorkflow.value));
function fieldBinding(fieldName: string): AgentJsonWorkflowToolInputField {
return inputs.value[fieldName] ?? { mode: 'ai' };
}
const inputSchemaLoaded = computed(
() =>
workflowsLoaded.value &&
!isLoadingWorkflows.value &&
Array.isArray(targetWorkflow.value?.nodes),
);
const hasInputErrors = computed(
() => inputSchemaLoaded.value && declaredInputFields.value.length > 0 && !inputsValid.value,
);
function fieldMode(fieldName: string): 'ai' | 'fixed' {
return fieldBinding(fieldName).mode;
}
watch(
[name, description, workflow, hasInputErrors],
([nameValue, descriptionValue, workflowValue]) => {
emit(
'update:valid',
nameValue.trim().length > 0 &&
descriptionValue.trim().length > 0 &&
workflowValue.trim().length > 0 &&
!hasInputErrors.value,
);
emit('update:node-name', nameValue);
},
{ immediate: true },
);
function fieldType(fieldName: string): string | undefined {
return declaredInputFields.value.find((field) => field.name === fieldName)?.type;
}
function fieldFixedValue(fieldName: string): string {
const binding = fieldBinding(fieldName);
if (binding.mode !== 'fixed') return '';
return formatWorkflowToolFixedValue(binding.value);
}
// The trigger's declared field type only matters once the user types a fixed
// value, so it surfaces as the input placeholder instead of a label.
const FIXED_VALUE_PLACEHOLDER_KEYS: Record<string, BaseTextKey> = {
string: 'agents.toolConfig.workflow.inputs.value.placeholder.string',
number: 'agents.toolConfig.workflow.inputs.value.placeholder.number',
boolean: 'agents.toolConfig.workflow.inputs.value.placeholder.boolean',
array: 'agents.toolConfig.workflow.inputs.value.placeholder.array',
object: 'agents.toolConfig.workflow.inputs.value.placeholder.object',
};
function fieldFixedValuePlaceholder(fieldName: string): string {
return i18n.baseText(
FIXED_VALUE_PLACEHOLDER_KEYS[fieldType(fieldName) ?? ''] ??
'agents.toolConfig.workflow.inputs.value.placeholder',
);
}
// Raw text being typed per field. While editing, the input shows this
// uncoerced text so fractional numbers (and in-progress JSON) survive
// each keystroke. On blur the text is parsed and committed to `inputs`.
const fieldInputText = ref<Record<string, string>>({});
function fieldInputDisplay(fieldName: string): string {
return fieldInputText.value[fieldName] ?? fieldFixedValue(fieldName);
}
function handleFieldInput(fieldName: string, value: string | number) {
fieldInputText.value = { ...fieldInputText.value, [fieldName]: String(value) };
}
function commitFieldFixedValue(fieldName: string) {
const raw = fieldInputText.value[fieldName];
if (raw === undefined) return;
setFieldFixedValue(fieldName, raw);
const next = { ...fieldInputText.value };
delete next[fieldName];
fieldInputText.value = next;
}
function setFieldMode(fieldName: string, nextMode: string) {
if (nextMode !== 'ai' && nextMode !== 'fixed') return;
// Discard any in-progress text when the mode changes.
const nextText = { ...fieldInputText.value };
delete nextText[fieldName];
fieldInputText.value = nextText;
if (nextMode === 'ai') {
const next = { ...inputs.value };
delete next[fieldName];
inputs.value = next;
return;
}
setFieldFixedValue(fieldName, fieldFixedValue(fieldName));
}
function setFieldFixedValue(fieldName: string, value: string | number) {
inputs.value = {
...inputs.value,
[fieldName]: {
mode: 'fixed',
value: parseWorkflowToolFixedValue(String(value), fieldType(fieldName)),
},
};
}
watch([() => props.submitCount, hasInputErrors], ([submitCount, hasErrors]) => {
if (submitCount && hasErrors) advancedExpanded.value = true;
});
function handleChangeName(newName: string) {
name.value = newName;
@ -327,16 +248,13 @@ function getWorkflowId() {
}
function getInputs(): WorkflowToolRef['inputs'] {
// Commit any in-progress text so the value isn't lost when the user
// confirms the modal without blurring the field first.
for (const fieldName of Object.keys(fieldInputText.value)) {
commitFieldFixedValue(fieldName);
}
if (Object.keys(inputs.value).length === 0) return undefined;
const bindings = toRaw(inputs.value);
if (Object.keys(bindings).length === 0) return undefined;
if (!inputSchemaLoaded.value) return bindings;
// Drop bindings for fields that no longer exist on the selected workflow.
const allowed = new Set(declaredInputFields.value.map((field) => field.name));
const pruned: NonNullable<WorkflowToolRef['inputs']> = {};
for (const [key, binding] of Object.entries(inputs.value)) {
for (const [key, binding] of Object.entries(bindings)) {
if (allowed.has(key)) pruned[key] = binding;
}
return Object.keys(pruned).length > 0 ? pruned : undefined;
@ -513,76 +431,77 @@ defineExpose({
</N8nText>
</div>
<div
v-if="declaredInputFields.length > 0"
:class="$style.field"
data-test-id="agent-workflow-tool-inputs"
>
<label :class="$style.label">
{{ i18n.baseText('agents.toolConfig.workflow.inputs') }}
</label>
<N8nText size="xsmall" color="text-light">
{{ i18n.baseText('agents.toolConfig.workflow.inputs.hint') }}
</N8nText>
<div :class="$style.inputFields">
<div
v-for="field in declaredInputFields"
:key="field.name"
:class="$style.field"
:data-test-id="`agent-workflow-tool-input-${field.name}`"
>
<N8nText size="small" :bold="true" :class="$style.inputName">{{ field.name }}</N8nText>
<div :class="$style.controlRow">
<N8nSelect
:model-value="fieldMode(field.name)"
:class="fieldMode(field.name) === 'fixed' ? $style.inputMode : $style.controlInput"
:data-test-id="`agent-workflow-tool-input-mode-${field.name}`"
@update:model-value="setFieldMode(field.name, $event)"
>
<N8nOption
value="ai"
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.ai')"
/>
<N8nOption
value="fixed"
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.fixed')"
/>
</N8nSelect>
<N8nInput
v-if="fieldMode(field.name) === 'fixed'"
:model-value="fieldInputDisplay(field.name)"
:class="$style.controlInput"
:placeholder="fieldFixedValuePlaceholder(field.name)"
:data-test-id="`agent-workflow-tool-input-value-${field.name}`"
@update:model-value="handleFieldInput(field.name, $event)"
@blur="commitFieldFixedValue(field.name)"
/>
</div>
</div>
</div>
</div>
<div :class="$style.toggleRow">
<div :class="$style.toggleText">
<N8nText size="small" :bold="true">
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs') }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs.hint') }}
</N8nText>
</div>
<N8nSwitch2
:model-value="allOutputs"
data-test-id="agent-workflow-tool-all-outputs"
@update:model-value="allOutputs = $event"
/>
</div>
<slot name="commonSettings" />
<div :class="$style.field">
<button
type="button"
:class="$style.advancedTrigger"
:aria-expanded="advancedExpanded"
aria-controls="workflow-tool-advanced"
data-test-id="agent-workflow-tool-advanced"
@click="advancedExpanded = !advancedExpanded"
>
<N8nText size="small" bold>{{
i18n.baseText('agents.toolConfig.workflow.advanced')
}}</N8nText>
<N8nIcon :icon="advancedExpanded ? 'chevron-up' : 'chevron-down'" size="small" />
</button>
<div id="workflow-tool-advanced" v-show="advancedExpanded" :class="$style.advancedContent">
<div :class="$style.toggleRow">
<div :class="$style.toggleText">
<N8nText size="small" :bold="true">
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs') }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs.hint') }}
</N8nText>
</div>
<N8nSwitch2
:model-value="allOutputs"
data-test-id="agent-workflow-tool-all-outputs"
@update:model-value="allOutputs = $event"
/>
</div>
<N8nText size="small" bold>{{
i18n.baseText('agents.toolConfig.workflow.inputs')
}}</N8nText>
<N8nText v-if="isLoadingWorkflows" size="small" color="text-light" role="status">
{{ i18n.baseText('agents.toolConfig.workflow.inputs.loading') }}
</N8nText>
<template v-else-if="!workflowsLoaded">
<N8nText size="small" color="danger" role="alert">
{{ i18n.baseText('agents.toolConfig.workflow.inputs.loadError') }}
</N8nText>
<N8nButton
variant="ghost"
size="small"
:label="i18n.baseText('generic.retry')"
@click="reloadWorkflows"
/>
</template>
<template v-else-if="inputSchemaLoaded">
<N8nText v-if="!declaredInputFields.length" size="xsmall" color="text-light">
{{ i18n.baseText('agents.toolConfig.workflow.inputs.empty') }}
</N8nText>
<WorkflowToolInputs
v-else
:key="targetWorkflowId"
v-model="inputs"
:fields="declaredInputFields"
:tool-name="name"
:submitted="(submitCount ?? 0) > 0"
@update:valid="inputsValid = $event"
/>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/_focus.scss' as focus;
.container {
display: flex;
flex-direction: column;
@ -659,18 +578,24 @@ defineExpose({
text-overflow: ellipsis;
}
.inputFields {
.advancedTrigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--3xs) 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
&:focus-visible {
@include focus.focus-ring;
}
}
.advancedContent {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
padding-top: var(--spacing--3xs);
}
.inputName {
text-transform: capitalize;
}
.inputMode {
flex: 0 0 180px;
}
</style>

View File

@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, provide, ref, toRaw, useId, watch } from 'vue';
import { N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AgentJsonWorkflowToolInputField } from '@n8n/api-types';
import { WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, type INodeProperties } from 'n8n-workflow';
import type { IUpdateInformation } from '@/Interface';
import { ExpressionLocalResolveContextSymbol, WorkflowDocumentStoreKey } from '@/app/constants';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import {
createWorkflowDocumentId,
disposeWorkflowDocumentStore,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import {
disposeWorkflowExecutionStateStore,
useWorkflowExecutionStateStore,
} from '@/app/stores/workflowExecutionState.store';
import { disposeNDVStore, useNDVStore } from '@/features/ndv/shared/ndv.store';
import ParameterInputFull from '@/features/ndv/parameters/components/ParameterInputFull.vue';
import {
formatWorkflowToolFixedValue,
parseWorkflowToolFixedValue,
type WorkflowToolInputFieldDef,
} from '../utils/workflowToolInputFields';
import type { WorkflowToolRef } from '../types';
const props = defineProps<{
fields: WorkflowToolInputFieldDef[];
toolName: string;
submitted: boolean;
}>();
const inputs = defineModel<NonNullable<WorkflowToolRef['inputs']>>({ required: true });
const emit = defineEmits<{ 'update:valid': [valid: boolean] }>();
const i18n = useI18n();
const id = useId();
const rawValues = ref<Record<string, string>>({});
const documentId = createWorkflowDocumentId(`workflow-tool-inputs-${id}`);
const documentStore = useWorkflowDocumentStore(documentId);
const ndvStore = useNDVStore(documentId);
const executionStore = useWorkflowExecutionStateStore(documentId);
provide(
WorkflowDocumentStoreKey,
computed(() => documentStore),
);
provide(
ExpressionLocalResolveContextSymbol,
computed<ExpressionLocalResolveContext>(() => ({
localResolve: true,
nodeName: props.toolName,
additionalKeys: {},
})),
);
watch(
() => props.toolName,
(name) => {
documentStore.setNodes([
{
id,
name,
type: WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
typeVersion: 2.2,
position: [0, 0],
parameters: {},
},
]);
ndvStore.setActiveNodeName(name, 'other');
},
{ immediate: true },
);
onBeforeUnmount(() => {
disposeNDVStore(ndvStore);
disposeWorkflowExecutionStateStore(executionStore);
disposeWorkflowDocumentStore(documentStore);
});
function bindingFor(name: string): AgentJsonWorkflowToolInputField {
const bindings = toRaw(inputs.value);
return Object.hasOwn(bindings, name) ? bindings[name] : { mode: 'ai' };
}
function descriptionFor(name: string): string {
const binding = bindingFor(name);
return binding.mode === 'ai' ? (binding.description ?? '') : '';
}
function parameterFor(field: WorkflowToolInputFieldDef): INodeProperties {
let type: INodeProperties['type'] = 'string';
if (field.type === 'number' || field.type === 'boolean') type = field.type;
if (['array', 'object', 'any'].includes(field.type ?? '')) type = 'json';
return {
displayName: `${field.name} (${field.type ?? 'string'})`,
name: field.name,
type,
default: '',
typeOptions: { rows: 3 },
};
}
function displayValue(field: WorkflowToolInputFieldDef) {
const binding = bindingFor(field.name);
if (binding.mode === 'ai') return '';
if (binding.mode === 'expression') return binding.value;
if (field.type === 'boolean') return binding.value === true;
const raw = toRaw(rawValues.value);
if (Object.hasOwn(raw, field.name)) return raw[field.name];
if (field.type === 'any') return JSON.stringify(binding.value);
return formatWorkflowToolFixedValue(binding.value);
}
function setMode(field: WorkflowToolInputFieldDef, mode: AgentJsonWorkflowToolInputField['mode']) {
let next = { ...toRaw(inputs.value) };
delete rawValues.value[field.name];
if (mode === 'ai') {
delete next[field.name];
} else if (mode === 'expression') {
next = { ...next, [field.name]: { mode, value: '=' } };
} else {
next = {
...next,
[field.name]: {
mode,
value: field.type === 'boolean' ? false : parseWorkflowToolFixedValue('', field.type),
},
};
}
inputs.value = next;
}
function updateValue(field: WorkflowToolInputFieldDef, update: IUpdateInformation) {
const raw = formatWorkflowToolFixedValue(update.value);
const mode = bindingFor(field.name).mode;
if (mode === 'ai') return;
if (mode === 'fixed') rawValues.value = { ...toRaw(rawValues.value), [field.name]: raw };
const binding: AgentJsonWorkflowToolInputField =
mode === 'expression'
? { mode, value: raw }
: { mode, value: parseWorkflowToolFixedValue(raw, field.type) };
inputs.value = { ...toRaw(inputs.value), [field.name]: binding };
}
function updateDescription(name: string, description: string) {
let next = { ...toRaw(inputs.value) };
if (description.trim()) next = { ...next, [name]: { mode: 'ai', description } };
else delete next[name];
inputs.value = next;
}
function inputError(field: WorkflowToolInputFieldDef): string | undefined {
const binding = bindingFor(field.name);
if (binding.mode === 'ai') return undefined;
if (binding.mode === 'expression') {
return binding.value.slice(1).trim()
? undefined
: i18n.baseText('agents.toolConfig.workflow.inputs.expression.empty');
}
const value = parseWorkflowToolFixedValue(
formatWorkflowToolFixedValue(binding.value),
field.type,
);
if (value === null) return undefined;
let valid = true;
switch (field.type) {
case 'number':
valid = typeof value === 'number' && Number.isFinite(value);
break;
case 'boolean':
valid = typeof value === 'boolean';
break;
case 'array':
valid = Array.isArray(value);
break;
case 'object':
valid = typeof value === 'object' && !Array.isArray(value);
break;
case 'any':
try {
JSON.parse(String(displayValue(field)));
} catch {
valid = false;
}
}
return valid
? undefined
: i18n.baseText('agents.toolConfig.workflow.inputs.value.invalid', {
interpolate: { type: field.type ?? 'string' },
});
}
const fieldsWithErrors = computed(() => props.fields.filter((field) => inputError(field)));
watch(fieldsWithErrors, (fields) => emit('update:valid', fields.length === 0), { immediate: true });
</script>
<template>
<div :class="$style.fields" data-test-id="agent-workflow-tool-inputs">
<div
v-for="(field, index) in fields"
:key="field.name"
:class="$style.field"
:data-test-id="`agent-workflow-tool-input-${encodeURIComponent(field.name)}`"
role="group"
:aria-label="field.name"
>
<ParameterInputFull
:parameter="parameterFor(field)"
:value="displayValue(field)"
:input-mode="bindingFor(field.name).mode"
:path="`inputs.${field.name}`"
:rows="3"
:display-options="true"
:options-overrides="{ hideFocusPanelButton: true }"
:external-issues="submitted && inputError(field) ? [inputError(field)!] : []"
@update="updateValue(field, $event)"
@text-input="updateValue(field, $event)"
@update:input-mode="setMode(field, $event)"
/>
<template v-if="bindingFor(field.name).mode === 'ai'">
<label :for="`${id}-${index}-description`" :class="$style.descriptionLabel">
{{ i18n.baseText('agents.toolConfig.workflow.inputs.description') }}
</label>
<N8nInput
:id="`${id}-${index}-description`"
:model-value="descriptionFor(field.name)"
type="textarea"
:rows="2"
:placeholder="i18n.baseText('agents.toolConfig.workflow.inputs.description.placeholder')"
@update:model-value="updateDescription(field.name, String($event))"
/>
</template>
<N8nText v-if="submitted && inputError(field)" size="xsmall" color="danger" role="alert">
{{ inputError(field) }}
</N8nText>
</div>
</div>
</template>
<style lang="scss" module>
.fields {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
min-width: 0;
}
.descriptionLabel {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
}
</style>

View File

@ -13,7 +13,8 @@ import {
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { FocusScope } from 'reka-ui';
import { computed, nextTick, useSlots, useTemplateRef } from 'vue';
import { computed, nextTick, provide, ref, useSlots, useTemplateRef } from 'vue';
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
const props = withDefaults(
defineProps<{
@ -62,12 +63,15 @@ const emit = defineEmits<{
const slots = useSlots();
const i18n = useI18n();
const body = useTemplateRef<HTMLElement>('body');
const parameterDialogs = ref(new Set<string>());
provide(ParameterInputModalContextKey, { openDialogs: parameterDialogs, appendTo: 'body' });
const trapFocus = computed(() => props.trapFocus && parameterDialogs.value.size === 0);
const hasFooter = computed(
() =>
props.showFooter ??
Boolean(slots.footer || slots.footerLeft || slots.footerBeforeCancel || slots.footerActions),
);
const dismissalBlocked = computed(() => props.busy || !props.trapFocus);
const dismissalBlocked = computed(() => props.busy || !trapFocus.value);
function onOpenChange(open: boolean) {
if (!open && dismissalBlocked.value) return;
@ -83,7 +87,9 @@ function onBack() {
}
function onEscapeKeyDown(event: KeyboardEvent) {
if (dismissalBlocked.value) event.preventDefault();
const isOutsideDialog =
event.target instanceof Node && !body.value?.closest('[role="dialog"]')?.contains(event.target);
if (dismissalBlocked.value || isOutsideDialog) event.preventDefault();
}
function onInteractOutside(event: Event) {
@ -108,8 +114,10 @@ function onOpenAutoFocus(event: Event) {
:open="props.open"
:size="props.size"
:stacked="props.stacked"
:trap-focus="props.trapFocus"
:disable-outside-pointer-events="props.disableOutsidePointerEvents"
:trap-focus="trapFocus"
:disable-outside-pointer-events="
props.disableOutsidePointerEvents && parameterDialogs.size === 0
"
:show-close-button="false"
@escape-key-down="onEscapeKeyDown"
@interact-outside="onInteractOutside"
@ -178,12 +186,7 @@ function onOpenAutoFocus(event: Event) {
</N8nText>
</N8nDialogHeader>
<FocusScope
v-if="!props.trapFocus"
as-child
@mount-auto-focus.prevent
@unmount-auto-focus.prevent
>
<FocusScope v-if="!trapFocus" as-child @mount-auto-focus.prevent @unmount-auto-focus.prevent>
<span hidden aria-hidden="true" />
</FocusScope>

View File

@ -130,7 +130,7 @@ export function useAgentToolCatalog() {
}),
);
async function loadWorkflows(projectId?: string): Promise<void> {
async function loadWorkflows(projectId?: string): Promise<boolean> {
try {
// Fetch all project workflows (not just those with a supported trigger)
// so unsupported ones can be shown greyed-out with a reason in the picker.
@ -150,8 +150,10 @@ export function useAgentToolCatalog() {
'updatedAt',
],
});
return true;
} catch (error) {
console.warn('[useAgentToolCatalog] failed to load workflows for project', error);
return false;
}
}

View File

@ -1,7 +1,13 @@
import { deepCopy } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import { toRaw } from 'vue';
import type { AgentJsonConfig } from '../types';
/** Copy JSON config without removing input names or reading Vue proxy methods. */
export function copyAgentConfig(config: AgentJsonConfig): AgentJsonConfig {
return jsonParse<AgentJsonConfig>(JSON.stringify(config, (_key, value: unknown) => toRaw(value)));
}
export function tryParseConfig(text: string): { ok: true; value: AgentJsonConfig } | { ok: false } {
try {
return { ok: true, value: JSON.parse(text) as AgentJsonConfig };
@ -41,7 +47,7 @@ export function getSlice(cfg: AgentJsonConfig | null, path: string | null | unde
/** Non-mutating write — returns a new config with `slice` placed at `path`. */
export function setSlice(cfg: AgentJsonConfig, path: string, slice: unknown): AgentJsonConfig {
const next = deepCopy(cfg);
const next = copyAgentConfig(cfg);
const parts = splitPath(path);
if (parts.length === 0) return slice as AgentJsonConfig;
let cur: unknown = next;

View File

@ -49,7 +49,7 @@ import { useKeybindings } from '@/app/composables/useKeybindings';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import { MODAL_CONFIRM } from '@/app/constants';
import { AGENT_EXTERNAL_UPDATE_NOTICE_DURATION, TIME } from '@/app/constants/durations';
import { deepCopy } from 'n8n-workflow';
import { copyAgentConfig } from '../utils/agentSectionEditor.utils';
import {
getAgent,
createAgent,
@ -764,7 +764,7 @@ watch(
config,
(c) => {
if (c) {
localConfig.value = deepCopy(c);
localConfig.value = copyAgentConfig(c);
syncAgentIdentityFromConfig(c);
}
},
@ -1753,7 +1753,7 @@ function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>, meta?: { source:
// session memory disabled. Normalize on save so legacy configs are
// corrected the next time the user makes a real edit, without mutating
// config during component mount.
config: normalizeAgentMemoryConfig(deepCopy(localConfig.value)),
config: normalizeAgentMemoryConfig(copyAgentConfig(localConfig.value)),
revision: configEditRevision,
baseConfigHash: configHash.value,
});
@ -1801,13 +1801,13 @@ const appliedSkills = caps.appliedSkills;
function replaceConfigAndScheduleSave(nextConfig: AgentJsonConfig) {
markConfigDraftEdited();
invalidateConfigValidation();
localConfig.value = deepCopy(nextConfig);
localConfig.value = copyAgentConfig(nextConfig);
syncAgentIdentityFromConfig(localConfig.value);
configAutosave.scheduleAutosave({
projectId: projectId.value,
agentId: agentId.value,
type: 'config',
config: normalizeAgentMemoryConfig(deepCopy(localConfig.value)),
config: normalizeAgentMemoryConfig(copyAgentConfig(localConfig.value)),
revision: configEditRevision,
baseConfigHash: configHash.value,
});

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import ExpressionEditorModalInput from './ExpressionEditorModal/ExpressionEditorModalInput.vue';
import { computed, ref, toRaw, watch } from 'vue';
import { computed, inject, ref, toRaw, watch } from 'vue';
import Close from 'virtual:icons/mdi/close';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
@ -22,6 +22,7 @@ import DraggableTarget from '@/app/components/DraggableTarget.vue';
import { dropInExpressionEditor } from '@/features/shared/editors/plugins/codemirror/dragAndDrop';
import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
import { useThrottleFn } from '@vueuse/core';
import { ElDialog } from 'element-plus';
@ -36,6 +37,7 @@ import {
import { useStyles } from '@n8n/composables/useStyles';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
const DEFAULT_LEFT_SIDEBAR_WIDTH = 360;
const parameterModalContext = inject(ParameterInputModalContextKey, undefined);
type Props = {
parameter: INodeProperties;
@ -166,7 +168,8 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
<template>
<ElDialog
width="calc(100% - var(--spacing--3xl))"
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
:append-to="parameterModalContext?.appendTo ?? `#${APP_MODALS_ELEMENT_ID}`"
:append-to-body="parameterModalContext?.appendTo === 'body'"
:class="$style.modal"
:model-value="dialogVisible"
:before-close="closeDialog"

View File

@ -1,7 +1,8 @@
import { computed, defineComponent, h, nextTick, reactive, shallowRef } from 'vue';
import { computed, defineComponent, h, nextTick, reactive, ref, shallowRef } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import {
ToolConfigCredentialSelectedKey,
ParameterInputModalContextKey,
WorkflowDocumentStoreKey,
WorkflowIdKey,
} from '@/app/constants/injectionKeys';
@ -1006,6 +1007,33 @@ describe('ParameterInput.vue', () => {
await waitFor(() => expect(expressionEditorInput).toHaveFocus());
});
it('hosts the expanded expression editor and clears its focus state on close or unmount', async () => {
const openDialogs = ref(new Set<string>());
const view = renderComponent({
global: {
provide: { [ParameterInputModalContextKey]: { openDialogs, appendTo: 'body' } },
},
props: {
path: 'name',
parameter: createTestNodeProperties({ name: 'name', type: 'string' }),
modelValue: '={{ $json.name }}',
},
});
await fireEvent.click(view.getByTestId('expander'));
const expandedInput = await view.findByTestId('expression-modal-input');
expect(view.container.contains(expandedInput)).toBe(false);
expect(expandedInput.closest('#app-modals')).toBeNull();
expect(openDialogs.value.size).toBe(1);
await fireEvent.click(view.getByRole('button', { name: 'Close this dialog' }));
await waitFor(() => expect(openDialogs.value.size).toBe(0));
await fireEvent.click(view.getByTestId('expander'));
expect(openDialogs.value.size).toBe(1);
view.unmount();
expect(openDialogs.value.size).toBe(0);
});
describe('when not in focus', () => {
test('should not focus after changing to expression ', async () => {
const { rerender, getByRole, getByTestId } = renderComponent({

View File

@ -1,10 +1,23 @@
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
import {
computed,
inject,
nextTick,
onBeforeUnmount,
onMounted,
onUpdated,
ref,
useId,
watch,
} from 'vue';
import { computedAsync, useDebounceFn, useElementSize } from '@vueuse/core';
import get from 'lodash/get';
import truncate from 'lodash/truncate';
import { CompactParameterHintsKey } from '@/app/constants/injectionKeys';
import {
CompactParameterHintsKey,
ParameterInputModalContextKey,
} from '@/app/constants/injectionKeys';
import type { INodeUpdatePropertiesInformation, IUpdateInformation, InputSize } from '@/Interface';
import type {
@ -199,6 +212,8 @@ const { isEnabled: isCollectionOverhaulEnabled } = useCollectionOverhaul();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
const onToolConfigCredentialSelected = inject(ToolConfigCredentialSelectedKey, undefined);
const parameterModalContext = inject(ParameterInputModalContextKey, undefined);
const parameterEditorId = useId();
const inputField = ref<InstanceType<typeof N8nInput | typeof N8nSelect> | HTMLElement>();
const wrapper = ref<HTMLDivElement>();
@ -210,6 +225,14 @@ const remoteParameterOptions = ref<INodePropertyOptions[]>([]);
const remoteParameterOptionsLoading = ref(false);
const remoteParameterOptionsLoadingIssues = ref<string | null>(null);
const textEditDialogVisible = ref(false);
watch(
() =>
codeEditDialogVisible.value || expressionEditDialogVisible.value || textEditDialogVisible.value,
(open) => {
if (open) parameterModalContext?.openDialogs.value.add(parameterEditorId);
else parameterModalContext?.openDialogs.value.delete(parameterEditorId);
},
);
const editDialogClosing = ref(false);
const tempValue = ref('');
const jsonValidationError = ref<string | null>(null);
@ -1421,6 +1444,7 @@ defineExpose({
});
onBeforeUnmount(() => {
parameterModalContext?.openDialogs.value.delete(parameterEditorId);
valueChangedDebounced.flush();
onUpdateTextInputDebounced.flush();
props.eventBus.off('optionSelected', optionSelected);
@ -1682,7 +1706,8 @@ onUpdated(async () => {
width="calc(100% - var(--spacing--3xl))"
:class="$style.modal"
:model-value="codeEditDialogVisible"
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
:append-to="parameterModalContext?.appendTo ?? `#${APP_MODALS_ELEMENT_ID}`"
:append-to-body="parameterModalContext?.appendTo === 'body'"
:title="`${i18n.baseText('codeEdit.edit')} ${i18n
.nodeText(ndvStore?.activeNode?.type)
.inputLabelDisplayName(parameter, path)}`"

View File

@ -117,6 +117,49 @@ describe('ParameterInputFull.vue', () => {
expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
});
it('keeps explicit input modes separate from the parameter value', async () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
codex: {
categories: ['AI'],
subcategories: { AI: ['Tools'] },
},
});
const {
findByDisplayValue,
getByRole,
getByText,
getByTestId,
queryByTestId,
emitted,
rerender,
} = renderComponent({
props: { inputMode: 'fixed', value: '=literal', displayOptions: true },
});
expect(await findByDisplayValue('=literal')).toBeInTheDocument();
await fireEvent.click(getByText('Expression'));
expect(emitted()['update:inputMode']).toEqual([['expression']]);
await fireEvent.click(getByTestId('from-ai-override-button'));
expect(emitted()['update:inputMode']).toEqual([['expression'], ['ai']]);
expect(emitted().update).toBeUndefined();
await rerender({ inputMode: 'ai' });
expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
expect(queryByTestId('selectable-list-selectable-description')).not.toBeInTheDocument();
await fireEvent.click(getByRole('button', { name: 'Edit value' }));
expect(emitted()['update:inputMode']).toEqual([['expression'], ['ai'], ['fixed']]);
expect(emitted().update).toBeUndefined();
await rerender({
inputMode: undefined,
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
});
expect(getByTestId('selectable-list-selectable-description')).toBeInTheDocument();
});
it('does not offer a model override when disabled', () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
codex: {
@ -156,7 +199,7 @@ describe('ParameterInputFull.vue', () => {
subcategories: { AI: ['Tools'] },
},
});
const { queryByTestId, getByTestId } = renderComponent({
const { queryByTestId, getByTestId, getByRole, emitted } = renderComponent({
props: {
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
disableFromAi: true,
@ -164,25 +207,30 @@ describe('ParameterInputFull.vue', () => {
});
expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
expect(queryByTestId('override-button')).not.toBeInTheDocument();
await fireEvent.click(getByRole('button', { name: 'Edit value' }));
expect(emitted().update).toEqual([[{ node: 'myParam', name: 'myParam', value: '' }]]);
});
it('shows external validation issues in the parameter row', () => {
it('keeps validation issues visible in read-only parameters', () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
codex: {
categories: ['AI'],
subcategories: { AI: ['Tools'] },
},
});
const { getByTestId } = renderComponent({
const { getByTestId, queryByRole } = renderComponent({
props: {
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
disableFromAi: true,
isReadOnly: true,
externalIssues: ["The model can't set the URL. Enter a fixed URL."],
},
});
expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
expect(getByTestId('parameter-issues')).toBeInTheDocument();
expect(queryByRole('button', { name: 'Edit value' })).not.toBeInTheDocument();
});
it('should render an existing fromAI override for static options parameters', async () => {

View File

@ -61,6 +61,7 @@ type Props = {
optionsOverrides?: ParameterOptionsOverrides;
externalIssues?: string[];
disableFromAi?: boolean;
inputMode?: 'ai' | 'fixed' | 'expression';
};
const props = withDefaults(defineProps<Props>(), {
@ -79,6 +80,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
blur: [];
update: [value: IUpdateInformation];
textInput: [value: IUpdateInformation];
'update:inputMode': [mode: NonNullable<Props['inputMode']>];
hover: [hovered: boolean];
drop: [value: string];
}>();
@ -118,15 +121,22 @@ const { capabilities: inputCapabilities } = useParameterInputContribution(
const canCreateContentOverride = computed(() => {
// An input that owns the from-AI override handles it separately
if (props.disableFromAi || !activeNode.value || inputCapabilities.value.ownsFromAiOverride)
return false;
if (props.disableFromAi || inputCapabilities.value.ownsFromAiOverride) return false;
if (props.inputMode !== undefined) return true;
if (!activeNode.value) return false;
return canBeContentOverride(props, activeNode.value);
});
const isContentOverride = computed(
() => fromAIOverride.value !== null && !!isFromAIOverrideValue(props.value?.toString() ?? ''),
);
const isContentOverride = computed(() => {
if (props.inputMode !== undefined) return props.inputMode === 'ai';
return fromAIOverride.value !== null && !!isFromAIOverrideValue(props.value?.toString() ?? '');
});
const inputParameter = computed(() => {
if (props.inputMode !== 'fixed') return props.parameter;
return { ...props.parameter, noDataExpression: true };
});
const hint = computed(() =>
i18n.nodeText(activeNode.value?.type).hint(props.parameter, props.path),
@ -140,16 +150,23 @@ const isResourceLocator = computed(
);
const isDropDisabled = computed(
() =>
props.parameter.noDataExpression ||
inputParameter.value.noDataExpression ||
isContentOverride.value ||
props.isReadOnly ||
inputCapabilities.value.disableDrop ||
isExpression.value,
);
const isExpression = computed(() => isValueExpression(props.parameter, props.value));
const isExpression = computed(() => {
if (props.inputMode !== undefined) return props.inputMode === 'expression';
return isValueExpression(props.parameter, props.value);
});
const useInlineSwitchLayout = computed(
() =>
props.parameter.type === 'boolean' && isCollectionOverhaulEnabled.value && !isExpression.value,
props.parameter.type === 'boolean' &&
isCollectionOverhaulEnabled.value &&
!isExpression.value &&
!isContentOverride.value,
);
const parameterTooltipText = computed(() =>
@ -176,7 +193,7 @@ const showExpressionSelector = computed(() => {
function onFocus() {
focused.value = true;
if (!props.parameter.noDataExpression) {
if (!inputParameter.value.noDataExpression) {
ndvStore.value.setMappableNDVInputFocus(props.parameter.displayName);
}
ndvStore.value.setFocusedInputPath(props.path ?? '');
@ -185,7 +202,7 @@ function onFocus() {
function onBlur() {
focused.value = false;
if (
!props.parameter.noDataExpression &&
!inputParameter.value.noDataExpression &&
ndvStore.value.focusedMappableInput === props.parameter.displayName
) {
ndvStore.value.setMappableNDVInputFocus('');
@ -207,6 +224,16 @@ function onWrapperMouseLeave() {
}
function optionSelected(command: string) {
if (props.inputMode !== undefined) {
if (command === 'addExpression') {
emit('update:inputMode', 'expression');
return;
}
if (command === 'removeExpression' || command === 'resetValue') {
emit('update:inputMode', 'fixed');
return;
}
}
if (isContentOverride.value && command === 'resetValue') {
removeOverride(true);
}
@ -218,6 +245,8 @@ function valueChanged(parameterData: IUpdateInformation) {
}
function onTextInput(parameterData: IUpdateInformation) {
emit('textInput', parameterData);
if (props.inputMode !== undefined) return;
if (isValueExpression(props.parameter, parameterData.value)) {
eventBus.value.emit('optionSelected', 'addExpression');
}
@ -327,6 +356,10 @@ const isSingleLineInput: ComputedRef<boolean> = computed(
);
function applyOverride() {
if (props.inputMode !== undefined) {
emit('update:inputMode', 'ai');
return;
}
if (!fromAIOverride.value) return;
telemetry.track('User turned on fromAI override', {
@ -343,22 +376,26 @@ function applyOverride() {
}
function removeOverride(clearField = false) {
if (!fromAIOverride.value) return;
if (props.inputMode !== undefined) {
emit('update:inputMode', 'fixed');
} else {
if (!fromAIOverride.value) return;
// In chat hub tool configuration context, always reset to default since expressions aren't supported
const shouldClear = clearField || isChatHubToolContext;
// Reset standalone node tools to their default because expressions are disabled.
const shouldClear = clearField || isChatHubToolContext;
telemetry.track('User turned off fromAI override', {
nodeType: activeNode.value?.type,
parameter: props.path,
});
valueChanged({
node: activeNode.value?.name,
name: props.path,
value: shouldClear
? props.parameter.default
: buildValueFromOverride(fromAIOverride.value, props, false),
});
telemetry.track('User turned off fromAI override', {
nodeType: activeNode.value?.type,
parameter: props.path,
});
valueChanged({
node: activeNode.value?.name,
name: props.path,
value: shouldClear
? props.parameter.default
: buildValueFromOverride(fromAIOverride.value, props, false),
});
}
void setTimeout(async () => {
await parameterInputWrapper.value?.focusInput();
parameterInputWrapper.value?.selectInput();
@ -384,7 +421,7 @@ function removeOverride(clearField = false) {
<template #default="{ droppable, activeDrop }">
<ParameterInputWrapper
ref="parameterInputWrapper"
:parameter="parameter"
:parameter="inputParameter"
:model-value="value"
:path="path"
:is-read-only="isReadOnly"
@ -421,6 +458,7 @@ function removeOverride(clearField = false) {
v-if="displayOptions"
:parameter="parameter"
:value="value"
:input-mode="inputMode"
:is-read-only="isReadOnly"
:show-options="displayOptions"
:show-expression-selector="showExpressionSelector"
@ -469,6 +507,7 @@ function removeOverride(clearField = false) {
<ParameterOptions
:parameter="parameter"
:value="value"
:input-mode="inputMode"
:is-read-only="isReadOnly"
:show-options="displayOptions"
:show-expression-selector="showExpressionSelector"
@ -489,7 +528,7 @@ function removeOverride(clearField = false) {
>
<template #default="{ droppable, activeDrop }">
<FromAiOverrideField
v-if="fromAIOverride && isContentOverride"
v-if="isContentOverride"
:is-read-only="isReadOnly"
:issues="externalIssues"
@close="removeOverride(!canCreateContentOverride)"
@ -497,7 +536,7 @@ function removeOverride(clearField = false) {
<div v-else>
<ParameterInputWrapper
ref="parameterInputWrapper"
:parameter="parameter"
:parameter="inputParameter"
:model-value="value"
:path="path"
:is-read-only="isReadOnly"
@ -545,6 +584,7 @@ function removeOverride(clearField = false) {
v-if="optionsPosition === 'bottom'"
:parameter="parameter"
:value="value"
:input-mode="inputMode"
:is-read-only="isReadOnly"
:show-options="displayOptions"
:show-expression-selector="showExpressionSelector"
@ -566,6 +606,7 @@ function removeOverride(clearField = false) {
v-if="displayOptions && optionsPosition === 'top-absolute'"
:parameter="parameter"
:value="value"
:input-mode="inputMode"
:is-read-only="isReadOnly"
:show-options="displayOptions"
:show-expression-selector="showExpressionSelector"
@ -578,7 +619,9 @@ function removeOverride(clearField = false) {
/>
</div>
<ParameterOverrideSelectableList
v-if="canCreateContentOverride && isContentOverride && fromAIOverride"
v-if="
inputMode === undefined && canCreateContentOverride && isContentOverride && fromAIOverride
"
v-model="fromAIOverride"
:parameter="parameter"
:path="path"

View File

@ -1,8 +1,7 @@
<script setup lang="ts">
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import AiStarsIcon from '@/app/components/AiStarsIcon.vue';
import { N8nTooltip } from '@n8n/design-system';
const i18n = useI18n();
withDefaults(
@ -24,13 +23,16 @@ const emit = defineEmits<{
<div>{{ i18n.baseText('parameterOverride.applyOverrideButtonTooltip') }}</div>
</template>
<div
<N8nIconButton
icon="sparkles"
variant="ghost"
:size="size"
icon-size="small"
:class="[$style.overrideButton, $style[position], $style[size]]"
:aria-label="i18n.baseText('parameterOverride.applyOverrideButtonTooltip')"
data-test-id="from-ai-override-button"
@click="emit('click')"
>
<AiStarsIcon size="large" />
</div>
/>
</N8nTooltip>
</template>

View File

@ -1,91 +1,88 @@
<script setup lang="ts">
import { N8nIcon, N8nIconButton, N8nTag, N8nText } from '@n8n/design-system';
import { i18n } from '@n8n/i18n';
import { N8nIcon, N8nText } from '@n8n/design-system';
import AiStarsIcon from '@/app/components/AiStarsIcon.vue';
import ParameterIssues from '../ParameterIssues.vue';
defineProps<{
isReadOnly?: boolean;
issues?: string[];
}>();
const emit = defineEmits<{
close: [];
}>();
const emit = defineEmits<{ close: [] }>();
</script>
<template>
<div :class="$style.contentOverrideContainer" data-test-id="fromAI-override-field">
<div :class="[$style.iconStars, $style.noCornersRight]">
<AiStarsIcon />
</div>
<div :class="$style.overrideInput">
<N8nText
v-n8n-html="i18n.baseText('parameterOverride.overridePanelText')"
color="text-dark"
size="small"
/>
</div>
<div v-if="issues?.length || !isReadOnly" :class="$style.actions">
<ParameterIssues v-if="issues?.length" :issues="issues" />
<div v-if="!isReadOnly" :class="$style.overrideCloseButton" @click="emit('close')">
<N8nIcon icon="x" size="small" />
</div>
</div>
<div :class="$style.field" data-test-id="fromAI-override-field">
<N8nTag
:text="i18n.baseText('parameterOverride.overridePanelText')"
:clickable="false"
size="md"
:class="$style.tag"
>
<template #tag>
<N8nIcon icon="sparkles" size="small" :class="$style.icon" />
<N8nText size="small" compact :class="$style.label">
{{ i18n.baseText('parameterOverride.overridePanelText') }}
</N8nText>
<N8nIconButton
v-if="!isReadOnly"
icon="x"
variant="ghost"
size="xsmall"
:class="$style.remove"
:aria-label="i18n.baseText('parameterOverride.editValue')"
@click="emit('close')"
/>
</template>
</N8nTag>
<ParameterIssues v-if="issues?.length" :class="$style.issues" :issues="issues" />
</div>
</template>
<style lang="scss" module>
.iconStars {
align-self: center;
padding-left: 8px;
width: 22px;
text-align: center;
border: 0;
color: var(--color--foreground--shade-2);
background-color: unset;
}
@use '@n8n/design-system/css/mixins/_input.scss' as input;
.noCornersRight {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.overrideInput {
height: 30px;
align-content: center;
overflow: hidden;
text-overflow: ellipsis;
}
.overrideCloseButton {
border: 0;
color: var(--color--text--tint-1);
padding: 0 var(--spacing--2xs);
align-self: stretch;
.field {
@include input.size-variables('small');
@include input.theme-variables;
display: flex;
align-items: center;
cursor: pointer;
&:hover {
color: var(--color--text);
}
width: 100%;
height: var(--input--height);
box-sizing: border-box;
gap: var(--spacing--4xs);
padding: var(--spacing--5xs);
border-radius: var(--input--radius);
background-color: var(--input--color--background);
box-shadow: inset var(--input--border--shadow);
}
.actions {
align-self: stretch;
display: flex;
align-items: center;
.tag {
--tag--min-width: 0;
--tag--max-width: 100%;
gap: var(--spacing--3xs);
height: 100%;
min-height: 0;
padding-block: 0;
}
.issues {
margin-left: auto;
}
.contentOverrideContainer {
display: flex;
align-items: center;
.icon {
flex-shrink: 0;
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
gap: var(--spacing--4xs);
border-radius: var(--radius);
background-color: var(--color--foreground);
}
.remove {
flex-shrink: 0;
color: var(--text-color--subtler);
}
</style>

View File

@ -25,6 +25,7 @@ interface Props {
parameter: INodeProperties;
isReadOnly: boolean;
value: NodeParameterValueType;
inputMode?: 'ai' | 'fixed' | 'expression';
showOptions?: boolean;
showExpressionSelector?: boolean;
showFocusPanel?: boolean;
@ -60,7 +61,10 @@ const ndvStore = injectNDVStoreIfProvided();
const activeNode = computed(() => ndvStore.value?.activeNode ?? null);
const isDefault = computed(() => props.parameter.default === props.value);
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
const isValueAnExpression = computed(() => {
if (props.inputMode !== undefined) return props.inputMode === 'expression';
return isValueExpression(props.parameter, props.value);
});
const editor = computed(() => getParameterTypeOption(props.parameter, 'editor'));
const isChatHubToolContext = inject(ChatHubToolContextKey, false);

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, watch, onMounted, nextTick, computed } from 'vue';
import { ref, watch, onMounted, nextTick, computed, inject } from 'vue';
import type { INodeProperties } from 'n8n-workflow';
import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
import { useI18n } from '@n8n/i18n';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
@ -24,6 +25,7 @@ const inputField = ref<HTMLInputElement | null>(null);
const tempValue = ref('');
const ndvStore = injectNDVStore();
const parameterModalContext = inject(ParameterInputModalContextKey, undefined);
const i18n = useI18n();
const activeNode = computed(() => ndvStore.value.activeNode);
@ -70,7 +72,8 @@ const closeDialog = () => {
<div v-if="dialogVisible">
<ElDialog
:model-value="dialogVisible"
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
:append-to="parameterModalContext?.appendTo ?? `#${APP_MODALS_ELEMENT_ID}`"
:append-to-body="parameterModalContext?.appendTo === 'body'"
width="80%"
:title="`${i18n.baseText('textEdit.edit')} ${i18n
.nodeText(activeNode?.type)

View File

@ -1,5 +1,5 @@
import * as Helpers from './helpers';
import type { NodeParameterValueType } from '../src';
import type { INodeExecutionData, NodeParameterValueType } from '../src';
import { Workflow } from '../src/workflow';
describe('WorkflowExpression', () => {
@ -30,8 +30,18 @@ describe('WorkflowExpression', () => {
await expression.releaseIsolate();
});
const evaluate = (value: NodeParameterValueType) =>
expression.getParameterValue(value, null, 0, 0, 'node', [], 'manual', {});
const evaluate = (value: NodeParameterValueType, inputData: INodeExecutionData[] = []) =>
expression.getParameterValue(value, null, 0, 0, 'node', inputData, 'manual', {});
it('preserves own input fields when copying expression results', () => {
const input = {
constructor: 'Constructor value',
toString: 'String value',
hasOwnProperty: 'Own value',
};
expect(evaluate('={{ { payload: $json } }}', [{ json: input }])).toEqual({ payload: input });
});
it('should resolve $parameter["&key"] sibling reference within an object', () => {
// n8n uses the `&`-prefixed syntax internally (e.g. in node parameter definitions)