diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts
index 0b25afbec60..6220889b172 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts
@@ -187,6 +187,78 @@ describe('AgentChatPanel — read-only build chat input', () => {
expect(chatInput.props('placeholder')).toBe('agents.builder.readonly.placeholder');
});
+ it('does not auto-send a seeded initialMessage when the build chat is read-only', async () => {
+ const sendMessage = vi.fn();
+ const loadHistory = vi.fn();
+ // Reset module cache so the doMock below replaces the stream mock set up
+ // by the earlier test in this describe block — without this, the cached
+ // AgentChatPanel.vue would keep using the previous mock and our
+ // `loadHistory` assertion would observe zero calls on the wrong fn.
+ vi.resetModules();
+ vi.doMock('../composables/useAgentChatStream', () => ({
+ useAgentChatStream: () => ({
+ messages: ref([]),
+ isStreaming: ref(false),
+ messagingState: ref('idle'),
+ fatalError: ref(null),
+ loadHistory,
+ sendMessage,
+ stopGenerating: vi.fn(),
+ resume: vi.fn(),
+ dismissFatalError: vi.fn(),
+ }),
+ }));
+ vi.doMock('../composables/useAgentTelemetry', () => ({
+ useAgentTelemetry: () => ({ trackSubmittedMessage: vi.fn() }),
+ }));
+ vi.doMock('../composables/agentTelemetry.utils', () => ({
+ buildAgentConfigFingerprint: vi.fn().mockResolvedValue({}),
+ }));
+
+ const beforeSend = vi.fn();
+ const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
+
+ const wrapper = mount(AgentChatPanel, {
+ props: {
+ projectId: 'p1',
+ agentId: 'a1',
+ endpoint: 'build',
+ agentConfig: null,
+ agentStatus: 'draft',
+ connectedTriggers: [],
+ canEditAgent: false,
+ initialMessage: 'seed build prompt',
+ beforeSend,
+ },
+ global: {
+ stubs: {
+ N8nButton: { template: '' },
+ N8nCallout: { template: '
' },
+ N8nIconButton: { template: '' },
+ AgentChatEmptyState: { template: '' },
+ AgentChatMessageList: { template: '' },
+ ChatInputBase: {
+ name: 'ChatInputBase',
+ template: '',
+ props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
+ },
+ },
+ },
+ });
+
+ await vi.waitFor(() => {
+ // Setup-time auto-send has run if it was going to.
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ expect(sendMessage).not.toHaveBeenCalled();
+ expect(beforeSend).not.toHaveBeenCalled();
+ expect(wrapper.emitted('initial-consumed')).toBeUndefined();
+ // History is loaded instead of auto-sending — so any existing thread
+ // renders rather than showing a misleading "build your agent" empty state.
+ expect(loadHistory).toHaveBeenCalledTimes(1);
+ });
+
it('does not disable ChatInputBase for endpoint=chat (test mode) regardless of canEditAgent', async () => {
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts
index 4498ae809ba..a4a59f9a613 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts
@@ -210,6 +210,37 @@ describe('AgentChatPanel', () => {
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
});
+ it('does not consume a seeded initial message in a read-only build chat', async () => {
+ const beforeSend = vi.fn();
+
+ const wrapper = mount(AgentChatPanel, {
+ props: {
+ projectId: 'p1',
+ agentId: 'a1',
+ endpoint: 'build',
+ canEditAgent: false,
+ initialMessage: 'seed build prompt',
+ agentConfig: {
+ name: 'Agent',
+ model: 'anthropic/claude-sonnet-4-5',
+ instructions: 'Help.',
+ },
+ agentStatus: 'draft',
+ connectedTriggers: [],
+ beforeSend,
+ },
+ });
+
+ await flushPromises();
+
+ expect(beforeSend).not.toHaveBeenCalled();
+ expect(sendMessageMock).not.toHaveBeenCalled();
+ expect(wrapper.emitted('initial-consumed')).toBeUndefined();
+ // History is loaded instead so any existing thread renders, rather than
+ // the misleading "describe your agent" empty state.
+ expect(loadHistoryMock).toHaveBeenCalledTimes(1);
+ });
+
it('disables chat and blocks sending while an interactive question is unresolved', async () => {
messagesMock.value = [openInteractiveMessage()];
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentPermissions.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentPermissions.test.ts
new file mode 100644
index 00000000000..2e5458fbbac
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentPermissions.test.ts
@@ -0,0 +1,140 @@
+import { createTestingPinia } from '@pinia/testing';
+import { setActivePinia } from 'pinia';
+import type { Scope } from '@n8n/permissions';
+import { mockedStore } from '@/__tests__/utils';
+import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
+import { useUsersStore } from '@/features/settings/users/users.store';
+import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
+import type { ProjectListItem } from '@/features/collaboration/projects/projects.types';
+import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
+import { useAgentPermissions } from '../composables/useAgentPermissions';
+
+const PROJECT_ID = 'project-1';
+
+const makeProject = (scopes: Scope[]): ProjectListItem => ({
+ id: PROJECT_ID,
+ name: 'Team Alpha',
+ icon: { type: 'icon', value: 'folder' },
+ type: ProjectTypes.Team,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ role: 'project:editor',
+ scopes,
+});
+
+describe('useAgentPermissions', () => {
+ let projectsStore: ReturnType>;
+ let usersStore: ReturnType>;
+ let sourceControlStore: ReturnType>;
+
+ beforeEach(() => {
+ setActivePinia(createTestingPinia());
+ projectsStore = mockedStore(useProjectsStore);
+ usersStore = mockedStore(useUsersStore);
+ sourceControlStore = mockedStore(useSourceControlStore);
+ projectsStore.myProjects = [];
+ usersStore.currentUser = null;
+ sourceControlStore.preferences = { branchReadOnly: false } as never;
+ });
+
+ it('grants permissions from project scopes when global scopes are absent', () => {
+ projectsStore.myProjects = [
+ makeProject([
+ 'agent:create',
+ 'agent:update',
+ 'agent:delete',
+ 'agent:publish',
+ 'agent:unpublish',
+ ]),
+ ];
+
+ const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
+ useAgentPermissions(PROJECT_ID);
+
+ expect(canCreate.value).toBe(true);
+ expect(canUpdate.value).toBe(true);
+ expect(canDelete.value).toBe(true);
+ expect(canPublish.value).toBe(true);
+ expect(canUnpublish.value).toBe(true);
+ });
+
+ it('grants permissions from global scopes when no project is found', () => {
+ usersStore.currentUser = {
+ globalScopes: [
+ 'agent:create',
+ 'agent:update',
+ 'agent:delete',
+ 'agent:publish',
+ 'agent:unpublish',
+ ],
+ } as never;
+
+ const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
+ useAgentPermissions(PROJECT_ID);
+
+ expect(canCreate.value).toBe(true);
+ expect(canUpdate.value).toBe(true);
+ expect(canDelete.value).toBe(true);
+ expect(canPublish.value).toBe(true);
+ expect(canUnpublish.value).toBe(true);
+ });
+
+ it('grants permissions when either scope source allows the action', () => {
+ projectsStore.myProjects = [makeProject(['agent:create', 'agent:update'])];
+ usersStore.currentUser = {
+ globalScopes: ['agent:delete'],
+ } as never;
+
+ const { canCreate, canUpdate, canDelete, canPublish } = useAgentPermissions(PROJECT_ID);
+
+ expect(canCreate.value).toBe(true);
+ expect(canUpdate.value).toBe(true);
+ expect(canDelete.value).toBe(true);
+ expect(canPublish.value).toBe(false);
+ });
+
+ it('returns false for every flag when no scopes match', () => {
+ projectsStore.myProjects = [makeProject([])];
+ usersStore.currentUser = { globalScopes: [] } as never;
+
+ const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
+ useAgentPermissions(PROJECT_ID);
+
+ expect(canCreate.value).toBe(false);
+ expect(canUpdate.value).toBe(false);
+ expect(canDelete.value).toBe(false);
+ expect(canPublish.value).toBe(false);
+ expect(canUnpublish.value).toBe(false);
+ });
+
+ it('blocks every flag when source control puts the branch in read-only mode', () => {
+ projectsStore.myProjects = [
+ makeProject([
+ 'agent:create',
+ 'agent:update',
+ 'agent:delete',
+ 'agent:publish',
+ 'agent:unpublish',
+ ]),
+ ];
+ usersStore.currentUser = {
+ globalScopes: [
+ 'agent:create',
+ 'agent:update',
+ 'agent:delete',
+ 'agent:publish',
+ 'agent:unpublish',
+ ],
+ } as never;
+ sourceControlStore.preferences = { branchReadOnly: true } as never;
+
+ const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
+ useAgentPermissions(PROJECT_ID);
+
+ expect(canCreate.value).toBe(false);
+ expect(canUpdate.value).toBe(false);
+ expect(canDelete.value).toBe(false);
+ expect(canPublish.value).toBe(false);
+ expect(canUnpublish.value).toBe(false);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue
index b1f240d221f..119dde853e1 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue
@@ -203,17 +203,19 @@ async function sendSeedMessage(message: string): Promise {
}
}
-if (seedMessage) {
- void sendSeedMessage(seedMessage);
+// Skip the seed when the build chat is read-only
+const consumesSeed = !!seedMessage && !isBuilderReadOnly.value;
+if (consumesSeed) {
+ void sendSeedMessage(seedMessage as string);
}
onMounted(() => {
- // A supplied `initialMessage` means the parent just minted a fresh session
- // and wants us to seed it with the first message — there's no thread to
- // load yet, and hitting the history endpoint would 404. The seed was
- // already sent synchronously during setup (see the `seedMessage` block
- // above).
- if (seedMessage) {
+ // When we actually seeded a message, there's no prior thread to load —
+ // the agent was just created in this panel and the history endpoint
+ // would 404. Otherwise (including the read-only suppression path) load
+ // whatever history exists so the panel shows real content instead of a
+ // misleading "describe your agent" empty state.
+ if (consumesSeed) {
return;
}
void loadHistory();
diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts
index e3663c98e5b..aa0add49b6d 100644
--- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts
+++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts
@@ -2,16 +2,23 @@ import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue'
import { getResourcePermissions } from '@n8n/permissions';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useUsersStore } from '@/features/settings/users/users.store';
+import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
type AgentPermissionKey = 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
export type AgentPermissions = Record<`can${Capitalize}`, ComputedRef>;
+// All five permissions gate mutations, so we additionally block them whenever
+// source control puts the instance in a read-only branch. Agents themselves
+// aren't tracked by source control, but `branchReadOnly` doubles as the
+// instance-wide "no writes" signal — matching how other resource views
+// (workflows, credentials, data tables) combine scopes with this flag.
export function useAgentPermissions(
projectId: MaybeRefOrGetter,
): AgentPermissions {
const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
+ const sourceControlStore = useSourceControlStore();
const projectScopes = computed(
() =>
@@ -22,9 +29,12 @@ export function useAgentPermissions(
const globalScopes = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).agent,
);
+ const isReadOnly = computed(() => sourceControlStore.preferences.branchReadOnly);
const pick = (key: AgentPermissionKey): ComputedRef =>
- computed(() => Boolean(globalScopes.value[key] ?? projectScopes.value[key]));
+ computed(
+ () => !isReadOnly.value && Boolean(globalScopes.value[key] ?? projectScopes.value[key]),
+ );
return {
canCreate: pick('create'),
diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue
index b604e4ff18a..85d8ce48c43 100644
--- a/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue
+++ b/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue
@@ -11,9 +11,8 @@ import InsightsSummary from '@/features/execution/insights/components/InsightsSu
import { useInsightsStore } from '@/features/execution/insights/insights.store';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
-import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
-import { getResourcePermissions } from '@n8n/permissions';
import { listAgents } from '../composables/useAgentApi';
+import { useAgentPermissions } from '../composables/useAgentPermissions';
import type { AgentResource } from '../types';
import { AGENT_BUILDER_VIEW, NEW_AGENT_VIEW } from '../constants';
import AgentCard from '../components/AgentCard.vue';
@@ -27,15 +26,10 @@ const rootStore = useRootStore();
const projectsStore = useProjectsStore();
const insightsStore = useInsightsStore();
const projectPages = useProjectPages();
-const sourceControlStore = useSourceControlStore();
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
-const canCreateAgent = computed(
- () =>
- !sourceControlStore.preferences.branchReadOnly &&
- !!getResourcePermissions(homeProject.value?.scopes)?.agent?.create,
-);
+const { canCreate: canCreateAgent } = useAgentPermissions(() => homeProject.value?.id);
const allAgents = ref([]);
const loading = ref(true);
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts
index 51a3e4d93ff..f7ba90fceb5 100644
--- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts
@@ -6,7 +6,7 @@ import * as router from 'vue-router';
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
import ProjectHeader from './ProjectHeader.vue';
import { useProjectsStore } from '../projects.store';
-import type { Project } from '../projects.types';
+import type { Project, ProjectListItem } from '../projects.types';
import { ProjectTypes } from '../projects.types';
import { VIEWS } from '@/app/constants';
import userEvent from '@testing-library/user-event';
@@ -306,9 +306,11 @@ describe('ProjectHeader', () => {
describe('new agent telemetry', () => {
beforeEach(() => {
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
- projectsStore.currentProject = createTestProject({
+ const project = createTestProject({
scopes: ['workflow:create', 'agent:create'],
});
+ projectsStore.currentProject = project;
+ projectsStore.myProjects = [project] as unknown as ProjectListItem[];
});
it('tracks source=button when the agent main button is clicked', async () => {
@@ -639,6 +641,7 @@ describe('ProjectHeader', () => {
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
const project = createTestProject({ scopes: ['agent:create'] });
projectsStore.currentProject = project;
+ projectsStore.myProjects = [project] as unknown as ProjectListItem[];
const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
@@ -650,6 +653,7 @@ describe('ProjectHeader', () => {
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
const project = createTestProject({ scopes: [] });
projectsStore.currentProject = project;
+ projectsStore.myProjects = [project] as unknown as ProjectListItem[];
const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue
index 75ce07245f6..901f11a9260 100644
--- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue
@@ -21,6 +21,7 @@ import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N
import { useUIStore } from '@/app/stores/ui.store';
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
import { NEW_AGENT_VIEW } from '@/features/agents/constants';
+import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions';
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
import { N8nButton, N8nHeading, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
@@ -80,6 +81,8 @@ const headerIcon = computed((): IconOrEmoji => {
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
+const { canCreate: canCreateAgent } = useAgentPermissions(() => homeProject.value?.id);
+
const isPersonalProject = computed(() => {
return homeProject.value?.type === ProjectTypes.Personal;
});
@@ -200,9 +203,7 @@ const createAgentButton = computed(() => ({
value: ACTION_TYPES.AGENT,
label: i18n.baseText('projects.header.create.agent'),
size: 'mini' as const,
- disabled:
- sourceControlStore.preferences.branchReadOnly ||
- !getResourcePermissions(homeProject.value?.scopes).agent.create,
+ disabled: !canCreateAgent.value,
}));
const selectedMainButtonType = computed(() => {
@@ -297,9 +298,7 @@ const menu = computed(() => {
items.push({
value: ACTION_TYPES.AGENT,
label: i18n.baseText('projects.header.create.agent'),
- disabled:
- sourceControlStore.preferences.branchReadOnly ||
- !getResourcePermissions(homeProject.value?.scopes).agent.create,
+ disabled: !canCreateAgent.value,
});
}