mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
fix(editor): Open new agents without Instance AI (#36462)
This commit is contained in:
@@ -20,7 +20,10 @@ const routerResolve = vi.fn((to: { name?: string; params?: Record<string, string
|
||||
href: `/${to.name ?? ''}/${Object.values(to.params ?? {}).join('/')}`,
|
||||
}));
|
||||
const routeQuery = reactive<Record<string, string | undefined>>({});
|
||||
const routeParams = reactive({ projectId: 'p1', agentId: 'a1' });
|
||||
let routeName = 'AgentBuilderView';
|
||||
type RouteGuard = (to: { params: Record<string, string> }) => void | Promise<void>;
|
||||
const routeGuards: { leave?: RouteGuard; update?: RouteGuard } = {};
|
||||
const openModalWithDataMock = vi.fn();
|
||||
const closeModalMock = vi.fn();
|
||||
const showMessageMock = vi.fn();
|
||||
@@ -56,11 +59,15 @@ vi.mock('vue-router', () => ({
|
||||
}),
|
||||
useRoute: () => ({
|
||||
name: routeName,
|
||||
params: { projectId: 'p1', agentId: 'a1' },
|
||||
params: routeParams,
|
||||
query: routeQuery,
|
||||
}),
|
||||
onBeforeRouteLeave: vi.fn(),
|
||||
onBeforeRouteUpdate: vi.fn(),
|
||||
onBeforeRouteLeave: (guard: RouteGuard) => {
|
||||
routeGuards.leave = guard;
|
||||
},
|
||||
onBeforeRouteUpdate: (guard: RouteGuard) => {
|
||||
routeGuards.update = guard;
|
||||
},
|
||||
RouterLink: { template: '<a><slot/></a>' },
|
||||
}));
|
||||
|
||||
@@ -634,12 +641,17 @@ function resetViewMocks() {
|
||||
openModalWithDataMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
routeName = 'AgentBuilderView';
|
||||
routeParams.projectId = 'p1';
|
||||
routeParams.agentId = 'a1';
|
||||
routeGuards.leave = undefined;
|
||||
routeGuards.update = undefined;
|
||||
agentEvalsFlagMock.enabled = false;
|
||||
generateDraftCasesMock.mockReset();
|
||||
generateDraftCasesMock.mockResolvedValue({ cases: [] });
|
||||
for (const key of Object.keys(routeQuery)) delete routeQuery[key];
|
||||
sessionThreads.length = 0;
|
||||
fetchedSessionThreads.length = 0;
|
||||
history.replaceState({}, '');
|
||||
sessionStorage.removeItem('N8N_DEBOUNCE_MULTIPLIER');
|
||||
// Reset to a built agent; tests that need an unbuilt agent override locally.
|
||||
intendedConfig = {
|
||||
@@ -703,6 +715,70 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
|
||||
expect(wrapper.find('[data-testid="agent-chat-mode-toggle"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('persists a route-backed agent only after its first configuration change', async () => {
|
||||
history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
|
||||
createAgentMock.mockResolvedValueOnce(makeAgentResponse());
|
||||
const wrapper = await renderView();
|
||||
const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
|
||||
|
||||
expect(getAgentMock).not.toHaveBeenCalled();
|
||||
expect(fetchConfigMock).not.toHaveBeenCalled();
|
||||
expect(createAgentMock).not.toHaveBeenCalled();
|
||||
expect(editor.props('agentUnsaved')).toBe(true);
|
||||
|
||||
editor.vm.$emit('update:config', { instructions: 'Answer support mail' });
|
||||
await vi.waitFor(() => expect(updateConfigMock).toHaveBeenCalled());
|
||||
|
||||
expect(createAgentMock).toHaveBeenCalledOnce();
|
||||
expect(createAgentMock).toHaveBeenCalledWith(expect.anything(), 'p1', expect.any(String), {
|
||||
id: 'a1',
|
||||
});
|
||||
expect(history.state.instanceAiPendingAgentId).toBeUndefined();
|
||||
await vi.waitFor(() => expect(fetchConfigMock).toHaveBeenCalledWith('p1', 'a1'));
|
||||
expect(fetchSessionThreadsMock).toHaveBeenCalledWith('p1', 'a1');
|
||||
});
|
||||
|
||||
it('settles a route-backed create before switching agents', async () => {
|
||||
history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
|
||||
const pendingCreate = Promise.withResolvers<ReturnType<typeof makeAgentResponse>>();
|
||||
createAgentMock.mockReturnValueOnce(pendingCreate.promise);
|
||||
getAgentMock.mockImplementation(async (_context, projectId: string, id: string) =>
|
||||
makeAgentResponse({ id, projectId }),
|
||||
);
|
||||
const wrapper = await renderView();
|
||||
const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
|
||||
|
||||
editor.vm.$emit('update:config', { instructions: 'Answer support mail' });
|
||||
await nextTick();
|
||||
expect(routeGuards.update).toBeDefined();
|
||||
const navigation = routeGuards.update?.({
|
||||
params: { projectId: 'p1', agentId: 'a2' },
|
||||
});
|
||||
await vi.waitFor(() => expect(createAgentMock).toHaveBeenCalledOnce());
|
||||
|
||||
pendingCreate.resolve(makeAgentResponse({ id: 'a1' }));
|
||||
await navigation;
|
||||
await flushPromises();
|
||||
|
||||
expect(history.state.instanceAiPendingAgentId).toBeUndefined();
|
||||
const agentAHistoryState = { ...history.state };
|
||||
getAgentMock.mockClear();
|
||||
|
||||
history.replaceState({}, '');
|
||||
routeParams.agentId = 'a2';
|
||||
await vi.waitFor(() =>
|
||||
expect(getAgentMock).toHaveBeenCalledWith(expect.anything(), 'p1', 'a2'),
|
||||
);
|
||||
|
||||
getAgentMock.mockClear();
|
||||
history.replaceState(agentAHistoryState, '');
|
||||
routeParams.agentId = 'a1';
|
||||
await vi.waitFor(() =>
|
||||
expect(getAgentMock).toHaveBeenCalledWith(expect.anything(), 'p1', 'a1'),
|
||||
);
|
||||
expect(editor.props('agentUnsaved')).toBe(false);
|
||||
});
|
||||
|
||||
it('loads credentials through the workflow-scoped credentials endpoint for the agent project', async () => {
|
||||
await renderView();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import NewAgentView from '../views/NewAgentView.vue';
|
||||
import { INSTANCE_AI_THREAD_VIEW } from '@/features/ai/instanceAi/constants';
|
||||
import { getPendingAgentAttachment } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
|
||||
import { AGENTS_LIST_VIEW, PROJECT_AGENTS } from '../constants';
|
||||
import { AGENTS_LIST_VIEW, AGENT_BUILDER_VIEW, PROJECT_AGENTS } from '../constants';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: { query: { projectId: 'project-1' } as Record<string, string> },
|
||||
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
|
||||
showError: vi.fn(),
|
||||
syncThread: vi.fn(),
|
||||
updateThreadMetadata: vi.fn(),
|
||||
instanceAiAvailable: true,
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -24,6 +25,13 @@ vi.mock('@n8n/i18n', () => ({
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: mocks.showError }),
|
||||
}));
|
||||
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAvailability', () => ({
|
||||
useInstanceAiAvailable: () => ({
|
||||
get value() {
|
||||
return mocks.instanceAiAvailable;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/features/ai/instanceAi/instanceAi.store', () => ({
|
||||
useInstanceAiStore: () => ({
|
||||
syncThread: mocks.syncThread,
|
||||
@@ -37,11 +45,27 @@ describe('NewAgentView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.route.query = { projectId: 'project-1' };
|
||||
mocks.instanceAiAvailable = true;
|
||||
history.replaceState({}, '');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('opens an unsaved agent artifact without creating the agent', async () => {
|
||||
it('opens an unpersisted agent in the manual builder when Instance AI is unavailable', async () => {
|
||||
mocks.instanceAiAvailable = false;
|
||||
|
||||
mount(NewAgentView);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.syncThread).not.toHaveBeenCalled();
|
||||
expect(mocks.updateThreadMetadata).not.toHaveBeenCalled();
|
||||
expect(mocks.replace).toHaveBeenCalledWith({
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: { projectId: 'project-1', agentId: 'aBcDeFgHiJkLmNoP' },
|
||||
state: { instanceAiPendingAgentId: 'aBcDeFgHiJkLmNoP' },
|
||||
});
|
||||
});
|
||||
|
||||
it('opens an unsaved agent artifact when Instance AI is available', async () => {
|
||||
mount(NewAgentView);
|
||||
await flushPromises();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router';
|
||||
import { N8nAssistantIcon, N8nButton, N8nIcon, type ActionDropdownItem } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import {
|
||||
@@ -82,6 +82,7 @@ import AgentPreviewDock from '../components/AgentPreviewDock.vue';
|
||||
import AgentVersionHistoryPanel from '../components/VersionHistory/AgentVersionHistoryPanel.vue';
|
||||
import { useInstanceAiHandoff } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
|
||||
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
|
||||
import { INSTANCE_AI_PENDING_AGENT_ID_STATE } from '@/features/ai/instanceAi/constants';
|
||||
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
|
||||
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
|
||||
import { buildAgentFixWithAssistantPrompt } from '../utils/fix-with-assistant';
|
||||
@@ -164,6 +165,17 @@ const agentId = computed(
|
||||
() =>
|
||||
(isArtifactMode.value ? props.artifactAgentId : undefined) ?? (route.params.agentId as string),
|
||||
);
|
||||
const pendingAgentIdFromHistory = (history.state as Record<string, unknown>)[
|
||||
INSTANCE_AI_PENDING_AGENT_ID_STATE
|
||||
];
|
||||
const routePendingAgentId = ref(
|
||||
typeof pendingAgentIdFromHistory === 'string' ? pendingAgentIdFromHistory : null,
|
||||
);
|
||||
const isRouteAgentPending = computed(() => {
|
||||
if (isArtifactMode.value) return false;
|
||||
return routePendingAgentId.value === agentId.value;
|
||||
});
|
||||
const isAgentPending = computed(() => props.artifactAgentPending || isRouteAgentPending.value);
|
||||
const previewOpenStorageKey = computed(function getPreviewOpenStorageKey() {
|
||||
return `N8N_AGENT_PREVIEW_OPEN:${projectId.value}:${agentId.value}`;
|
||||
});
|
||||
@@ -727,6 +739,16 @@ interface McpAvailabilitySnapshot {
|
||||
*/
|
||||
const persistFlights = new Map<string, Promise<void>>();
|
||||
const persistedAgentsByTarget = new Map<string, AgentResource>();
|
||||
|
||||
function clearRoutePendingState(targetAgentId: string) {
|
||||
if (isArtifactMode.value) return;
|
||||
const historyState = history.state as Record<string, unknown>;
|
||||
if (historyState[INSTANCE_AI_PENDING_AGENT_ID_STATE] !== targetAgentId) return;
|
||||
const { [INSTANCE_AI_PENDING_AGENT_ID_STATE]: _, ...state } = historyState;
|
||||
history.replaceState(state, '');
|
||||
if (routePendingAgentId.value === targetAgentId) routePendingAgentId.value = null;
|
||||
}
|
||||
|
||||
async function ensureAgentPersisted(): Promise<void> {
|
||||
if (!isUnsaved.value) return;
|
||||
const targetProjectId = projectId.value;
|
||||
@@ -736,6 +758,7 @@ async function ensureAgentPersisted(): Promise<void> {
|
||||
if (persistedAgent) {
|
||||
isUnsaved.value = false;
|
||||
agent.value = persistedAgent;
|
||||
clearRoutePendingState(targetAgentId);
|
||||
emit('persisted', persistedAgent);
|
||||
return;
|
||||
}
|
||||
@@ -750,12 +773,12 @@ async function ensureAgentPersisted(): Promise<void> {
|
||||
);
|
||||
persistedAgentsByTarget.set(targetKey, created);
|
||||
upsertProjectAgentsListCache(targetProjectId, created);
|
||||
clearRoutePendingState(targetAgentId);
|
||||
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return;
|
||||
isUnsaved.value = false;
|
||||
agent.value = created;
|
||||
// Lets the artifact host record that this agent is real now. Without it a
|
||||
// reload would re-enter draft mode and overwrite the saved config with an
|
||||
// empty one, since the pending marker itself cannot be deleted.
|
||||
// Lets an artifact host replace its pending metadata. Route-backed drafts
|
||||
// clear their history marker after the create succeeds instead.
|
||||
emit('persisted', created);
|
||||
})();
|
||||
persistFlights.set(targetKey, flight);
|
||||
@@ -930,6 +953,21 @@ async function flushAutosave() {
|
||||
]);
|
||||
}
|
||||
|
||||
async function flushPendingRouteDraftBeforeNavigation() {
|
||||
if (!isRouteAgentPending.value || !isUnsaved.value) return;
|
||||
await flushAutosave();
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(flushPendingRouteDraftBeforeNavigation);
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
const nextProjectId = Array.isArray(to.params.projectId)
|
||||
? to.params.projectId[0]
|
||||
: to.params.projectId;
|
||||
const nextAgentId = Array.isArray(to.params.agentId) ? to.params.agentId[0] : to.params.agentId;
|
||||
if (nextProjectId === projectId.value && nextAgentId === agentId.value) return;
|
||||
await flushPendingRouteDraftBeforeNavigation();
|
||||
});
|
||||
|
||||
async function beforePreviewSend() {
|
||||
// Autosave failures already use their config/skill/MCP-specific error toasts.
|
||||
await flushAutosave();
|
||||
@@ -1361,7 +1399,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean }
|
||||
const sessionsFetchRequestId = ++latestSessionsFetchRequestId;
|
||||
const targetProjectId = projectId.value;
|
||||
const targetAgentId = agentId.value;
|
||||
const targetArtifactPending = props.artifactAgentPending;
|
||||
const targetAgentPending = isAgentPending.value;
|
||||
const isCurrentInitialization = () =>
|
||||
!disposed && sessionsFetchRequestId === latestSessionsFetchRequestId;
|
||||
clearTimeout(externalRefreshTimer);
|
||||
@@ -1433,7 +1471,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean }
|
||||
// blank config the backend would have written, and let the first edit
|
||||
// create it (see `ensureAgentPersisted`). The personalisation backfill is
|
||||
// skipped too — it schedules a save, which would persist on mount alone.
|
||||
isUnsaved.value = targetArtifactPending;
|
||||
isUnsaved.value = targetAgentPending;
|
||||
if (isUnsaved.value) {
|
||||
const draftConfig: AgentJsonConfig = {
|
||||
name: locale.baseText('agents.new.defaultName'),
|
||||
@@ -1542,7 +1580,7 @@ watch(
|
||||
// When a pending artifact becomes persisted under the same id, hydrate its
|
||||
// agent-scoped state without unmounting the editor or any in-flight setup UI.
|
||||
watch(
|
||||
[projectId, agentId, () => props.artifactAgentPending],
|
||||
[projectId, agentId, isAgentPending],
|
||||
([nextProjectId, nextAgentId, pending], [previousProjectId, previousAgentId]) => {
|
||||
const sameTarget = nextProjectId === previousProjectId && nextAgentId === previousAgentId;
|
||||
void initialize({ preserveState: sameTarget && !pending });
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
INSTANCE_AI_PENDING_AGENT_METADATA_KEY,
|
||||
INSTANCE_AI_THREAD_VIEW,
|
||||
} from '@/features/ai/instanceAi/constants';
|
||||
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
|
||||
import { stashPendingAgentAttachment } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
|
||||
import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store';
|
||||
import { AGENTS_LIST_VIEW, PROJECT_AGENTS } from '../constants';
|
||||
import { AGENTS_LIST_VIEW, AGENT_BUILDER_VIEW, PROJECT_AGENTS } from '../constants';
|
||||
|
||||
/** Shape `generateNanoId` produces, and the only shape the create API accepts. */
|
||||
const MINTED_AGENT_ID = /^[0-9A-Za-z]{16}$/;
|
||||
@@ -22,14 +23,13 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const instanceAiAvailable = useInstanceAiAvailable();
|
||||
const instanceAiStore = useInstanceAiStore();
|
||||
|
||||
/**
|
||||
* Opens a new-agent artifact without persisting anything. The agent id is
|
||||
* minted here and carried on the thread so both paths that can create the
|
||||
* agent — a config edit in the artifact, or an agent-building chat request —
|
||||
* create it under the same id. Nothing reaches the database until one of them
|
||||
* happens, so abandoning the thread leaves no empty agent behind.
|
||||
* Opens an unpersisted agent draft in Instance AI when available, or directly
|
||||
* in the manual builder otherwise. Both paths persist only after the first
|
||||
* configuration change and use the id minted by the entry point.
|
||||
*/
|
||||
onMounted(async () => {
|
||||
const projectId = route.query.projectId;
|
||||
@@ -50,8 +50,17 @@ onMounted(async () => {
|
||||
typeof clickedAgentId === 'string' && MINTED_AGENT_ID.test(clickedAgentId)
|
||||
? clickedAgentId
|
||||
: generateNanoId();
|
||||
const threadId = uuidv4();
|
||||
try {
|
||||
if (!instanceAiAvailable.value) {
|
||||
await router.replace({
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: { projectId, agentId },
|
||||
state: { [INSTANCE_AI_PENDING_AGENT_ID_STATE]: agentId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = uuidv4();
|
||||
await instanceAiStore.syncThread(threadId, projectId, {
|
||||
source: 'agent_builder_page',
|
||||
origin: 'internal',
|
||||
|
||||
Reference in New Issue
Block a user