fix(editor): Open new agents without Instance AI (#36462)

This commit is contained in:
Michael Drury
2026-08-18 14:35:18 +00:00
committed by GitHub
parent cc340df75a
commit 38ec2ec6d0
4 changed files with 166 additions and 19 deletions
@@ -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',