mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
feat(editor): Open workflow artifact when builder spawns to edit it (#30862)
This commit is contained in:
parent
b80738bb18
commit
cdf9b4ffb0
|
|
@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest';
|
|||
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
|
||||
import {
|
||||
getLatestBuildResult,
|
||||
getLatestBuilderTarget,
|
||||
getLatestExecutionId,
|
||||
getLatestDataTableResult,
|
||||
getLatestDeletedDataTableId,
|
||||
|
|
@ -185,6 +186,99 @@ describe('getLatestBuildResult', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getLatestBuilderTarget', () => {
|
||||
test('returns undefined for node with no children', () => {
|
||||
expect(getLatestBuilderTarget(makeAgentNode())).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined when builder child has no targetResource id (create flow)', () => {
|
||||
const builder = makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow' },
|
||||
});
|
||||
const parent = makeAgentNode({ children: [builder] });
|
||||
expect(getLatestBuilderTarget(parent)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns agentId and workflowId when builder is spawned with targetResource.id (edit flow)', () => {
|
||||
const builder = makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-existing' },
|
||||
});
|
||||
const parent = makeAgentNode({ children: [builder] });
|
||||
expect(getLatestBuilderTarget(parent)).toEqual({
|
||||
agentId: 'agent-builder-1',
|
||||
workflowId: 'wf-existing',
|
||||
});
|
||||
});
|
||||
|
||||
test('detects builder by kind even when role differs', () => {
|
||||
const builder = makeAgentNode({
|
||||
agentId: 'agent-builder-2',
|
||||
role: 'background-task',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-1' },
|
||||
});
|
||||
const parent = makeAgentNode({ children: [builder] });
|
||||
expect(getLatestBuilderTarget(parent)?.workflowId).toBe('wf-1');
|
||||
});
|
||||
|
||||
test('ignores non-workflow targetResource types', () => {
|
||||
const credSetup = makeAgentNode({
|
||||
agentId: 'agent-cred-1',
|
||||
role: 'browser-credential-setup',
|
||||
kind: 'browser-setup',
|
||||
status: 'active',
|
||||
targetResource: { type: 'credential', id: 'cred-1' },
|
||||
});
|
||||
const parent = makeAgentNode({ children: [credSetup] });
|
||||
expect(getLatestBuilderTarget(parent)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns the latest builder when multiple are present', () => {
|
||||
const builderA = makeAgentNode({
|
||||
agentId: 'agent-builder-a',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'completed',
|
||||
targetResource: { type: 'workflow', id: 'wf-a' },
|
||||
});
|
||||
const builderB = makeAgentNode({
|
||||
agentId: 'agent-builder-b',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-b' },
|
||||
});
|
||||
const parent = makeAgentNode({ children: [builderA, builderB] });
|
||||
expect(getLatestBuilderTarget(parent)?.workflowId).toBe('wf-b');
|
||||
});
|
||||
|
||||
test('walks nested children depth-first, newest last', () => {
|
||||
const nestedBuilder = makeAgentNode({
|
||||
agentId: 'agent-builder-nested',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-nested' },
|
||||
});
|
||||
const intermediate = makeAgentNode({
|
||||
agentId: 'agent-intermediate',
|
||||
role: 'planner',
|
||||
children: [nestedBuilder],
|
||||
});
|
||||
const parent = makeAgentNode({ children: [intermediate] });
|
||||
expect(getLatestBuilderTarget(parent)?.workflowId).toBe('wf-nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestExecutionId', () => {
|
||||
test('returns undefined for node with no tool calls', () => {
|
||||
expect(getLatestExecutionId(makeAgentNode())).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -398,6 +398,129 @@ describe('useCanvasPreview', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('auto-open on builder spawn (edit flow)', () => {
|
||||
test('opens canvas as soon as an edit-mode builder spawns with targetResource.id', async () => {
|
||||
const ctx = setup();
|
||||
registerWorkflow(ctx.thread, 'wf-existing', 'Existing WF');
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-existing' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeTabId.value).toBe('wf-existing');
|
||||
expect(ctx.activeWorkflowId.value).toBe('wf-existing');
|
||||
expect(ctx.isPreviewVisible.value).toBe(true);
|
||||
});
|
||||
|
||||
test('does not open canvas when the builder has no targetResource id (create flow)', async () => {
|
||||
const ctx = setup();
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeTabId.value).toBeUndefined();
|
||||
expect(ctx.isPreviewVisible.value).toBe(false);
|
||||
});
|
||||
|
||||
test('does not open canvas while hydrating historical messages', async () => {
|
||||
const ctx = setup();
|
||||
ctx.thread.isHydratingThread = true;
|
||||
registerWorkflow(ctx.thread, 'wf-historical', 'Past WF');
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-historical',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'completed',
|
||||
targetResource: { type: 'workflow', id: 'wf-historical' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeTabId.value).toBeUndefined();
|
||||
expect(ctx.isPreviewVisible.value).toBe(false);
|
||||
});
|
||||
|
||||
test('switches to the latest edit target when a new builder spawns', async () => {
|
||||
const ctx = setup();
|
||||
registerWorkflow(ctx.thread, 'wf-a', 'WF A');
|
||||
registerWorkflow(ctx.thread, 'wf-b', 'WF B');
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-a',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'completed',
|
||||
targetResource: { type: 'workflow', id: 'wf-a' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
expect(ctx.activeTabId.value).toBe('wf-a');
|
||||
|
||||
ctx.thread.messages = [
|
||||
...ctx.thread.messages,
|
||||
makeMessage({
|
||||
id: 'msg-2',
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-b',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-b' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeTabId.value).toBe('wf-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-open data table preview', () => {
|
||||
test('auto-opens data table preview when streaming', async () => {
|
||||
const ctx = setup();
|
||||
|
|
|
|||
|
|
@ -158,6 +158,132 @@ describe('useResourceRegistry', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('producedArtifacts — targetResource registration', () => {
|
||||
test('registers a builder sub-agent targetResource as a produced workflow', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-edit', name: 'Existing WF' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.get('wf-edit')).toEqual(
|
||||
expect.objectContaining({ type: 'workflow', id: 'wf-edit', name: 'Existing WF' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores targetResource without an id (create flow)', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.size).toBe(0);
|
||||
});
|
||||
|
||||
test('ignores credential targetResource (not surfaced in the artifacts panel)', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-cred-1',
|
||||
role: 'browser-credential-setup',
|
||||
kind: 'browser-setup',
|
||||
status: 'active',
|
||||
targetResource: { type: 'credential', id: 'cred-1' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.size).toBe(0);
|
||||
});
|
||||
|
||||
test('falls back to Untitled when targetResource has no name', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'active',
|
||||
targetResource: { type: 'workflow', id: 'wf-edit' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.get('wf-edit')?.name).toBe('Untitled');
|
||||
});
|
||||
|
||||
test('later build-workflow result overwrites the placeholder name', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-1',
|
||||
role: 'workflow-builder',
|
||||
kind: 'builder',
|
||||
status: 'completed',
|
||||
targetResource: { type: 'workflow', id: 'wf-edit' },
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'submit-workflow',
|
||||
result: { workflowId: 'wf-edit', workflowName: 'Renamed' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.size).toBe(1);
|
||||
expect(producedArtifacts.value.get('wf-edit')?.name).toBe('Renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('producedArtifacts — updates and merges', () => {
|
||||
test('second write to the same workflow id updates the existing entry', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ export interface BuildResult {
|
|||
toolCallId: string;
|
||||
}
|
||||
|
||||
export interface BuilderTarget {
|
||||
/** Unique per spawn — changes even when a new builder targets the same workflow. */
|
||||
agentId: string;
|
||||
workflowId: string;
|
||||
}
|
||||
|
||||
export interface WorkflowSetupResult {
|
||||
workflowId: string;
|
||||
/** Unique per operation — changes even when the same workflow is set up again. */
|
||||
|
|
@ -51,6 +57,31 @@ export function getLatestBuildResult(node: InstanceAiAgentNode): BuildResult | u
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an agent tree depth-first (most recent last) and returns the agentId
|
||||
* and workflowId of the latest workflow-builder sub-agent that was spawned
|
||||
* with a concrete `targetResource.id` — i.e. an edit-mode builder that
|
||||
* already knows which existing workflow it is modifying. Used to open the
|
||||
* canvas preview at spawn time, before the first build-workflow tool call
|
||||
* returns a result.
|
||||
*/
|
||||
export function getLatestBuilderTarget(node: InstanceAiAgentNode): BuilderTarget | undefined {
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
const child = node.children[i];
|
||||
const nested = getLatestBuilderTarget(child);
|
||||
if (nested) return nested;
|
||||
const isBuilder = child.kind === 'builder' || child.role === 'workflow-builder';
|
||||
if (
|
||||
isBuilder &&
|
||||
child.targetResource?.type === 'workflow' &&
|
||||
typeof child.targetResource.id === 'string'
|
||||
) {
|
||||
return { agentId: child.agentId, workflowId: child.targetResource.id };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const WORKFLOW_SETUP_TOOLS = new Set(['setup-workflow', 'apply-workflow-credentials']);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { computed, ref, watch, type Ref } from 'vue';
|
|||
import type { IconName } from '@n8n/design-system';
|
||||
import {
|
||||
getLatestBuildResult,
|
||||
getLatestBuilderTarget,
|
||||
getLatestExecutionId,
|
||||
getLatestWorkflowSetupResult,
|
||||
getLatestDataTableResult,
|
||||
|
|
@ -173,6 +174,35 @@ export function useCanvasPreview({
|
|||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
// --- Auto-open canvas when an edit-mode builder spawns ---
|
||||
// The workflow-builder carries the existing workflow id in
|
||||
// `targetResource.id` from the moment it is spawned. Opening the preview
|
||||
// then — instead of waiting for the first build-workflow result — lets the
|
||||
// user see what is being edited as soon as the sub-agent is called.
|
||||
// Keyed by agentId so a fresh builder spawn re-triggers the preview.
|
||||
|
||||
const latestBuilderTarget = computed(() => {
|
||||
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
||||
const msg = thread.messages[i];
|
||||
if (msg.agentTree) {
|
||||
const target = getLatestBuilderTarget(msg.agentTree);
|
||||
if (target) return target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => latestBuilderTarget.value?.agentId,
|
||||
(agentId) => {
|
||||
if (!agentId || !latestBuilderTarget.value) return;
|
||||
if (thread.isHydratingThread) return;
|
||||
|
||||
activeTabId.value = latestBuilderTarget.value.workflowId;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
// --- Refresh preview when setup-workflow / apply-workflow-credentials completes ---
|
||||
// These tools modify the workflow (credentials, parameters) but aren't detected
|
||||
// by getLatestBuildResult. Refresh the preview so the iframe shows the latest state.
|
||||
|
|
|
|||
|
|
@ -197,7 +197,25 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the agent's `targetResource` as a produced artifact when it carries
|
||||
* a concrete resource id (e.g. a workflow-builder spawned to edit an existing
|
||||
* workflow). Surfacing this at spawn time — before the first build-workflow
|
||||
* tool result arrives — lets the artifacts panel show the workflow as soon as
|
||||
* the sub-agent starts, instead of waiting for the first edit.
|
||||
*/
|
||||
function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections): void {
|
||||
const target = node.targetResource;
|
||||
if (!target?.id) return;
|
||||
if (target.type !== 'workflow' && target.type !== 'data-table') return;
|
||||
|
||||
const existing = col.produced.get(target.id);
|
||||
const name = optionalString(target.name) ?? existing?.name ?? 'Untitled';
|
||||
recordProduced(col, { type: target.type, id: target.id, name });
|
||||
}
|
||||
|
||||
function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void {
|
||||
extractFromTargetResource(node, col);
|
||||
for (const tc of node.toolCalls) {
|
||||
extractFromToolCall(tc, col);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user