mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
feat(editor): Add Evals tab shell to the agent config panel (no-changelog) (#35530)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
7c61f2826a
commit
cd50064343
|
|
@ -7733,7 +7733,7 @@
|
|||
"agents.builder.header.tab.executions": "Sessions",
|
||||
"agents.builder.header.tab.settings": "Settings",
|
||||
"agents.builder.header.tab.memory": "Memory",
|
||||
"agents.builder.header.tab.evaluations": "Evaluations",
|
||||
"agents.builder.header.tab.agentEvals": "Evals",
|
||||
"agents.builder.header.tab.raw": "Raw",
|
||||
"agents.builder.header.workspaceCrumb": "Workspace",
|
||||
"agents.builder.header.projectFallback": "Project",
|
||||
|
|
@ -7766,12 +7766,11 @@
|
|||
"agents.builder.executions.count": "{count} session | {count} sessions",
|
||||
"agents.builder.settings.description": "Configure memory, sub-agent routing, and advanced runtime settings.",
|
||||
"agents.builder.raw.description": "Agent JSON configuration",
|
||||
"agents.builder.evaluations.comingSoon": "Evaluations functionality is coming soon.",
|
||||
"agents.builder.evaluations.configuredInCode": "{count} evaluation configured in code | {count} evaluations configured in code",
|
||||
"agents.builder.evaluations.type.check": "Check",
|
||||
"agents.builder.evaluations.type.judge": "Judge",
|
||||
"agents.builder.evaluations.credentialConfigured": "Credential configured",
|
||||
"agents.builder.evaluations.emptyPrefix": "No evaluations configured.",
|
||||
"agents.builder.agentEvals.empty.title": "No test cases yet",
|
||||
"agents.builder.agentEvals.empty.description": "Draft a few realistic requests from your agent's own instructions, run them through the agent, then tell us which answers are wrong.",
|
||||
"agents.builder.agentEvals.empty.generate": "Generate test cases",
|
||||
"agents.builder.agentEvals.generated": "{count} test case generated | {count} test cases generated",
|
||||
"agents.builder.agentEvals.generateError": "Couldn't generate test cases",
|
||||
"agents.builder.quickActions.memoriesUsed.count": "{count} memory | {count} memories",
|
||||
"agents.builder.quickActions.memoriesUsed.keyMemory": "Memory",
|
||||
"agents.builder.capabilities.title": "Capabilities",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export const STORES = {
|
|||
API_KEYS: 'apiKeys',
|
||||
EVALUATION: 'evaluation',
|
||||
EVAL_COLLECTIONS: 'evalCollections',
|
||||
AGENT_EVALS: 'agentEvals',
|
||||
FOLDERS: 'folders',
|
||||
MODULES: 'modules',
|
||||
FOCUS_PANEL: 'focusPanel',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { createTestingPinia } from '@pinia/testing';
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs';
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) =>
|
||||
|
|
@ -127,12 +129,13 @@ vi.setConfig({ testTimeout: 30_000 });
|
|||
|
||||
async function mountColumn(
|
||||
overrides: Partial<{
|
||||
activeMainTab: 'agent' | 'knowledge' | 'sessions' | 'settings';
|
||||
activeMainTab: AgentBuilderMainTab;
|
||||
mainTabOptions: Array<{
|
||||
label: string;
|
||||
value: 'agent' | 'knowledge' | 'sessions' | 'settings';
|
||||
value: AgentBuilderMainTab;
|
||||
}>;
|
||||
knowledgeBaseEnabled: boolean;
|
||||
canEditAgent: boolean;
|
||||
}> = {},
|
||||
) {
|
||||
const { default: AgentBuilderEditorColumn } = await import(
|
||||
|
|
@ -162,7 +165,7 @@ async function mountColumn(
|
|||
knowledgeBaseEnabled: overrides.knowledgeBaseEnabled ?? true,
|
||||
appliedSkills: [],
|
||||
connectedTriggers: [],
|
||||
canEditAgent: true,
|
||||
canEditAgent: overrides.canEditAgent ?? true,
|
||||
executionsDescription: '',
|
||||
},
|
||||
global: {
|
||||
|
|
@ -244,6 +247,7 @@ describe('AgentBuilderEditorColumn', () => {
|
|||
['knowledge', 'agent-knowledge-tab-content'],
|
||||
['sessions', 'agent-sessions-tab-content'],
|
||||
['settings', 'agent-settings-tab-content'],
|
||||
['evals', 'agent-evals-tab-content'],
|
||||
] as const)('renders the %s tab through the shared tab panel', async (activeMainTab, testId) => {
|
||||
const wrapper = await mountColumn({ activeMainTab });
|
||||
|
||||
|
|
@ -252,6 +256,22 @@ describe('AgentBuilderEditorColumn', () => {
|
|||
expect(panels[0].attributes('data-testid')).toBe(testId);
|
||||
});
|
||||
|
||||
it('forwards the evals generate request to the host', async () => {
|
||||
const wrapper = await mountColumn({ activeMainTab: 'evals' });
|
||||
|
||||
// The shell owns no generation flow yet, so the request has to reach the
|
||||
// host rather than being emitted into nothing.
|
||||
wrapper.getComponent({ name: 'AgentEvalsSection' }).vm.$emit('generate');
|
||||
|
||||
expect(wrapper.emitted('generate-eval-cases')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('disables the evals CTA for a read-only agent', async () => {
|
||||
const wrapper = await mountColumn({ activeMainTab: 'evals', canEditAgent: false });
|
||||
|
||||
expect(wrapper.getComponent({ name: 'AgentEvalsSection' }).props('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses embedded session list spacing inside the Sessions tab panel', async () => {
|
||||
const wrapper = await mountColumn({ activeMainTab: 'sessions' });
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,12 @@ vi.mock('../composables/useAgentApi', () => ({
|
|||
getAgentConfigValidation: getAgentConfigValidationMock,
|
||||
}));
|
||||
|
||||
const generateDraftCasesMock = vi.fn();
|
||||
vi.mock('../agentEvals.api', () => ({
|
||||
getDatasets: vi.fn().mockResolvedValue([]),
|
||||
generateDraftCases: (...args: unknown[]) => generateDraftCasesMock(...args),
|
||||
}));
|
||||
|
||||
const builderTelemetryMock = vi.hoisted(() => ({
|
||||
fetchInitialTriggersBaseline: vi.fn().mockResolvedValue(null),
|
||||
trackTriggerAdded: vi.fn(),
|
||||
|
|
@ -792,6 +798,37 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('generates eval cases and confirms the result with a toast', async () => {
|
||||
generateDraftCasesMock.mockResolvedValue({
|
||||
datasetId: 'd1',
|
||||
dataTableId: 'dt1',
|
||||
cases: [
|
||||
{ input: 'a', whatToCheck: 'x' },
|
||||
{ input: 'b', whatToCheck: 'y' },
|
||||
],
|
||||
});
|
||||
const wrapper = await renderView();
|
||||
|
||||
wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).vm.$emit('generate-eval-cases');
|
||||
await flushPromises();
|
||||
|
||||
expect(generateDraftCasesMock).toHaveBeenCalledWith(expect.anything(), 'p1', 'a1', {});
|
||||
// Nothing renders the generated cases yet, so the toast is the only
|
||||
// signal the work landed — without it the click looks like a no-op.
|
||||
expect(showMessageMock).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
});
|
||||
|
||||
it('surfaces a failed eval-case generation', async () => {
|
||||
generateDraftCasesMock.mockRejectedValue(new Error('no model configured'));
|
||||
const wrapper = await renderView();
|
||||
|
||||
wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).vm.$emit('generate-eval-cases');
|
||||
await flushPromises();
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalled();
|
||||
expect(showMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
});
|
||||
|
||||
it('uploads knowledge files for an unpublished agent', async () => {
|
||||
// Default makeAgentResponse() has activeVersionId: null (unpublished).
|
||||
const wrapper = await renderView({ knowledgeBaseEnabled: true });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { configure } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import AgentEvalsSection from '../components/AgentEvalsSection.vue';
|
||||
|
||||
// Components use `data-testid`; the global setup configures `data-test-id`.
|
||||
configure({ testIdAttribute: 'data-testid' });
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({ baseText: (key: string) => `mocked-${key}` }),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(AgentEvalsSection);
|
||||
|
||||
describe('AgentEvalsSection', () => {
|
||||
it('renders the first-run state with its title, description and CTA', () => {
|
||||
const { getByTestId, getByText } = renderComponent();
|
||||
|
||||
expect(getByTestId('agent-evals-section')).toBeInTheDocument();
|
||||
expect(getByTestId('agent-evals-empty-state')).toBeInTheDocument();
|
||||
// Asserting on the mocked key prefix verifies the keys we intended rather
|
||||
// than arbitrary copy.
|
||||
expect(getByText('mocked-agents.builder.agentEvals.empty.title')).toBeInTheDocument();
|
||||
expect(getByText('mocked-agents.builder.agentEvals.empty.description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits generate when the CTA is clicked', async () => {
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('agent-evals-generate-button'));
|
||||
|
||||
expect(emitted('generate')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('disables the CTA for users who cannot edit the agent', () => {
|
||||
const { getByTestId } = renderComponent({ props: { disabled: true } });
|
||||
|
||||
expect(getByTestId('agent-evals-generate-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAgentEvalsStore } from '../agentEvals.store';
|
||||
import type { AgentEvalDatasetRecord } from '../agentEvals.types';
|
||||
|
||||
const { getDatasets, generateDraftCases } = vi.hoisted(() => ({
|
||||
getDatasets: vi.fn(),
|
||||
generateDraftCases: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../agentEvals.api', () => ({
|
||||
getDatasets,
|
||||
generateDraftCases,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: vi.fn(() => ({
|
||||
restApiContext: { instanceId: 'test-instance-id' },
|
||||
})),
|
||||
}));
|
||||
|
||||
const PROJECT_ID = 'project-1';
|
||||
const AGENT_ID = 'agent-1';
|
||||
|
||||
const dataset = (id: string, name = `dataset-${id}`): AgentEvalDatasetRecord => ({
|
||||
id,
|
||||
name,
|
||||
description: null,
|
||||
agentId: AGENT_ID,
|
||||
columnMapping: null,
|
||||
createdById: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
datasetSource: 'data_table',
|
||||
datasetRef: { dataTableId: 'dt-1' },
|
||||
});
|
||||
|
||||
describe('useAgentEvalsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchDatasets', () => {
|
||||
it('caches datasets under the agent id', async () => {
|
||||
getDatasets.mockResolvedValue([dataset('d1')]);
|
||||
const store = useAgentEvalsStore();
|
||||
|
||||
await store.fetchDatasets(PROJECT_ID, AGENT_ID);
|
||||
|
||||
expect(getDatasets).toHaveBeenCalledWith(
|
||||
{ instanceId: 'test-instance-id' },
|
||||
PROJECT_ID,
|
||||
AGENT_ID,
|
||||
);
|
||||
expect(store.getDatasets(AGENT_ID)).toHaveLength(1);
|
||||
// Another agent's cache must stay empty — switching agents in the
|
||||
// builder should never surface the previous agent's datasets.
|
||||
expect(store.getDatasets('agent-2')).toEqual([]);
|
||||
});
|
||||
|
||||
it('distinguishes "not loaded" from "loaded but empty"', async () => {
|
||||
getDatasets.mockResolvedValue([]);
|
||||
const store = useAgentEvalsStore();
|
||||
|
||||
expect(store.isLoaded(AGENT_ID)).toBe(false);
|
||||
await store.fetchDatasets(PROJECT_ID, AGENT_ID);
|
||||
|
||||
expect(store.isLoaded(AGENT_ID)).toBe(true);
|
||||
expect(store.getDatasets(AGENT_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears the loading flag when the request rejects', async () => {
|
||||
getDatasets.mockRejectedValue(new Error('boom'));
|
||||
const store = useAgentEvalsStore();
|
||||
|
||||
await expect(store.fetchDatasets(PROJECT_ID, AGENT_ID)).rejects.toThrow('boom');
|
||||
|
||||
expect(store.isLoadingDatasets(AGENT_ID)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDraftCases', () => {
|
||||
it('re-reads the dataset list once the server has persisted the drafts', async () => {
|
||||
generateDraftCases.mockResolvedValue({
|
||||
datasetId: 'd1',
|
||||
dataTableId: 'dt-1',
|
||||
cases: [{ input: 'hi', whatToCheck: 'is polite' }],
|
||||
});
|
||||
getDatasets.mockResolvedValue([dataset('d1')]);
|
||||
const store = useAgentEvalsStore();
|
||||
|
||||
const result = await store.generateDraftCases(PROJECT_ID, AGENT_ID, { count: 3 });
|
||||
|
||||
expect(generateDraftCases).toHaveBeenCalledWith(
|
||||
{ instanceId: 'test-instance-id' },
|
||||
PROJECT_ID,
|
||||
AGENT_ID,
|
||||
{ count: 3 },
|
||||
);
|
||||
expect(result.cases).toHaveLength(1);
|
||||
// The generate response carries drafts, not the dataset row, so the
|
||||
// cache has to come from the follow-up read.
|
||||
expect(getDatasets).toHaveBeenCalledTimes(1);
|
||||
expect(store.getDatasets(AGENT_ID).map((d) => d.id)).toEqual(['d1']);
|
||||
});
|
||||
|
||||
it('clears the generating flag when generation rejects', async () => {
|
||||
generateDraftCases.mockRejectedValue(new Error('no model'));
|
||||
const store = useAgentEvalsStore();
|
||||
|
||||
await expect(store.generateDraftCases(PROJECT_ID, AGENT_ID)).rejects.toThrow('no model');
|
||||
|
||||
expect(store.isGeneratingCases(AGENT_ID)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type {
|
||||
AgentEvalDatasetRecord,
|
||||
GenerateDraftCasesOptions,
|
||||
GenerateDraftCasesResult,
|
||||
} from '@n8n/api-types';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
|
||||
// REST path helper, kept inline so callers can't build a URL that drifts from
|
||||
// the routes in the agent-evals controller. Every route below it is gated
|
||||
// per-user on `101_agent_evals` server-side as well as by `useAgentEvalsFlag`.
|
||||
//
|
||||
// Reads the eval surface needs to render itself, and nothing more. Dataset
|
||||
// create/update/delete belong with the dataset-management UI that uses them;
|
||||
// run and result reads wait on their paginated response shapes.
|
||||
const evalsPath = (projectId: string, agentId: string) =>
|
||||
`/projects/${projectId}/agents/v2/${agentId}/evals`;
|
||||
|
||||
export const getDatasets = async (context: IRestApiContext, projectId: string, agentId: string) => {
|
||||
return await makeRestApiRequest<AgentEvalDatasetRecord[]>(
|
||||
context,
|
||||
'GET',
|
||||
`${evalsPath(projectId, agentId)}/datasets`,
|
||||
);
|
||||
};
|
||||
|
||||
// Drafts cases from the agent's own config and persists them as a new dataset,
|
||||
// so the response carries both the dataset it created and the drafts to review.
|
||||
export const generateDraftCases = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: GenerateDraftCasesOptions = {},
|
||||
) => {
|
||||
return await makeRestApiRequest<GenerateDraftCasesResult>(
|
||||
context,
|
||||
'POST',
|
||||
`${evalsPath(projectId, agentId)}/generate`,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
import * as agentEvalsApi from './agentEvals.api';
|
||||
import type { AgentEvalDatasetRecord, GenerateDraftCasesOptions } from './agentEvals.types';
|
||||
|
||||
/**
|
||||
* Client state for an agent's eval datasets.
|
||||
*
|
||||
* Runs, per-case results and ratings are deliberately not here yet: their list
|
||||
* responses are being reshaped into paginated envelopes, so binding them now
|
||||
* would only have to be redone. Add them alongside the views that read them.
|
||||
*/
|
||||
export const useAgentEvalsStore = defineStore(STORES.AGENT_EVALS, () => {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
// Keyed by agentId so switching agents inside the builder can't render the
|
||||
// previous agent's datasets.
|
||||
const datasetsByAgentId = ref<Record<string, AgentEvalDatasetRecord[]>>({});
|
||||
|
||||
const loadingDatasets = ref<Record<string, boolean>>({});
|
||||
const generatingCases = ref<Record<string, boolean>>({});
|
||||
|
||||
const getDatasets = (agentId: string) => datasetsByAgentId.value[agentId] ?? [];
|
||||
|
||||
// Absence of a cache entry is "not loaded yet", not "none" — callers that
|
||||
// need to distinguish the two should check `isLoaded` before `getDatasets`.
|
||||
const isLoaded = (agentId: string) => datasetsByAgentId.value[agentId] !== undefined;
|
||||
|
||||
const isLoadingDatasets = (agentId: string) => loadingDatasets.value[agentId] === true;
|
||||
|
||||
const isGeneratingCases = (agentId: string) => generatingCases.value[agentId] === true;
|
||||
|
||||
const setDatasets = (agentId: string, datasets: AgentEvalDatasetRecord[]) => {
|
||||
datasetsByAgentId.value = { ...datasetsByAgentId.value, [agentId]: datasets };
|
||||
};
|
||||
|
||||
const fetchDatasets = async (projectId: string, agentId: string) => {
|
||||
loadingDatasets.value = { ...loadingDatasets.value, [agentId]: true };
|
||||
try {
|
||||
const datasets = await agentEvalsApi.getDatasets(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
agentId,
|
||||
);
|
||||
setDatasets(agentId, datasets);
|
||||
return datasets;
|
||||
} finally {
|
||||
loadingDatasets.value = { ...loadingDatasets.value, [agentId]: false };
|
||||
}
|
||||
};
|
||||
|
||||
// The response carries the drafts, not the dataset row, so the list is
|
||||
// re-read rather than patched. Best-effort: generation already succeeded
|
||||
// server-side and cost model credits, so a transient refresh failure must not
|
||||
// surface as a generation failure (user retries → duplicate dataset). A stale
|
||||
// cache self-heals on the next fetch.
|
||||
const generateDraftCases = async (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: GenerateDraftCasesOptions = {},
|
||||
) => {
|
||||
generatingCases.value = { ...generatingCases.value, [agentId]: true };
|
||||
try {
|
||||
const result = await agentEvalsApi.generateDraftCases(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
agentId,
|
||||
options,
|
||||
);
|
||||
await fetchDatasets(projectId, agentId).catch(() => null);
|
||||
return result;
|
||||
} finally {
|
||||
generatingCases.value = { ...generatingCases.value, [agentId]: false };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getDatasets,
|
||||
isLoaded,
|
||||
isLoadingDatasets,
|
||||
isGeneratingCases,
|
||||
fetchDatasets,
|
||||
generateDraftCases,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Re-exports from @n8n/api-types so agent-eval FE callers don't reach across
|
||||
// packages. New shared type? Export it from `@n8n/api-types` first.
|
||||
//
|
||||
// Run/result/rating shapes are deliberately absent: their response types are
|
||||
// still being reshaped into paginated envelopes, so nothing here should bind to
|
||||
// them yet.
|
||||
|
||||
export type {
|
||||
AgentEvalDatasetRecord,
|
||||
AgentEvalDraftCase,
|
||||
GenerateDraftCasesOptions,
|
||||
GenerateDraftCasesResult,
|
||||
} from '@n8n/api-types';
|
||||
|
|
@ -25,6 +25,7 @@ import AgentMcpPanel from './AgentMcpPanel.vue';
|
|||
import AgentMemoryPanel from './AgentMemoryPanel.vue';
|
||||
import AgentSubAgentsPanel from './AgentSubAgentsPanel.vue';
|
||||
import AgentBuilderTabPanel from './AgentBuilderTabPanel.vue';
|
||||
import AgentEvalsSection from './AgentEvalsSection.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
activeMainTab: AgentBuilderMainTab;
|
||||
|
|
@ -43,6 +44,7 @@ const props = defineProps<{
|
|||
canEditAgent: boolean;
|
||||
agentAvailableInMcp?: boolean;
|
||||
executionsDescription: string;
|
||||
generatingEvalCases?: boolean;
|
||||
tasksReloadKey?: number;
|
||||
artifactMode?: boolean;
|
||||
/** No agent row exists yet, so agent-scoped endpoints would 404. */
|
||||
|
|
@ -78,6 +80,7 @@ const emit = defineEmits<{
|
|||
'toggle-mcp-access': [enabled: boolean];
|
||||
'tasks-changed': [];
|
||||
'agent-changed': [];
|
||||
'generate-eval-cases': [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -255,6 +258,17 @@ const i18n = useI18n();
|
|||
</N8nCard>
|
||||
</div>
|
||||
</AgentBuilderTabPanel>
|
||||
|
||||
<AgentBuilderTabPanel
|
||||
v-else-if="activeMainTab === 'evals'"
|
||||
data-testid="agent-evals-tab-content"
|
||||
>
|
||||
<AgentEvalsSection
|
||||
:disabled="childrenDisabled"
|
||||
:generating="generatingEvalCases"
|
||||
@generate="emit('generate-eval-cases')"
|
||||
/>
|
||||
</AgentBuilderTabPanel>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Read-only evaluations list. Evaluations are declared in the agent's TS
|
||||
* source via `.eval(new Eval(...))` — not in the JSON config — so this
|
||||
* panel is populated from `AgentSchema.evaluations` returned by a runtime
|
||||
* inspection endpoint.
|
||||
*
|
||||
* TODO: wire this to a backend endpoint that returns `AgentSchema`. Until
|
||||
* then `schema` is always `null` and the panel renders the empty state.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { N8nCard, N8nText, N8nIcon } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AgentSchema } from '../types';
|
||||
|
||||
const props = withDefaults(defineProps<{ schema?: AgentSchema | null }>(), {
|
||||
schema: null,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const evals = computed(() => props.schema?.evaluations ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.panel" data-testid="agent-evals-panel">
|
||||
<template v-if="evals.length > 0">
|
||||
<N8nCard v-for="evalItem in evals" :key="evalItem.name" :class="$style.evalCard">
|
||||
<div :class="$style.evalHeader">
|
||||
<N8nText :bold="true" size="small">{{ evalItem.name }}</N8nText>
|
||||
<span
|
||||
:class="[
|
||||
$style.typeBadge,
|
||||
evalItem.type === 'check' ? $style.badgeCheck : $style.badgeJudge,
|
||||
]"
|
||||
>
|
||||
<N8nText size="xsmall" :bold="true">{{
|
||||
evalItem.type === 'check'
|
||||
? i18n.baseText('agents.builder.evaluations.type.check')
|
||||
: i18n.baseText('agents.builder.evaluations.type.judge')
|
||||
}}</N8nText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="evalItem.hasCredential" :class="$style.credentialRow">
|
||||
<N8nIcon icon="lock" size="xsmall" :class="$style.keyIcon" />
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{
|
||||
evalItem.credentialName ??
|
||||
i18n.baseText('agents.builder.evaluations.credentialConfigured')
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<N8nText v-if="evalItem.description" size="small" color="text-light">
|
||||
{{ evalItem.description }}
|
||||
</N8nText>
|
||||
</N8nCard>
|
||||
</template>
|
||||
|
||||
<div v-else :class="$style.dashedCard">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.evaluations.emptyPrefix') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.panel {
|
||||
padding: var(--spacing--lg);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.evalCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.evalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing--5xs) var(--spacing--3xs);
|
||||
border-radius: var(--radius--sm);
|
||||
}
|
||||
|
||||
.badgeCheck {
|
||||
background-color: var(--color--orange-100);
|
||||
color: var(--background--brand--active);
|
||||
}
|
||||
|
||||
.badgeJudge {
|
||||
background-color: var(--color--purple-100);
|
||||
color: var(--color--purple-700);
|
||||
}
|
||||
|
||||
.credentialRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
|
||||
.keyIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashedCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
padding: var(--spacing--sm);
|
||||
border: var(--border-width) dashed var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: var(--font-family--monospace, 'SF Mono', monospace);
|
||||
font-size: var(--font-size--2xs);
|
||||
background-color: var(--background--active);
|
||||
padding: 0 var(--spacing--5xs);
|
||||
border-radius: var(--radius--sm);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Container for the agent's eval surface. Today it only renders the first-run
|
||||
* state; the case list and review views mount here as they land, which is why
|
||||
* the empty state is a branch rather than the whole component.
|
||||
*/
|
||||
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
generating?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
generate: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.section" data-testid="agent-evals-section">
|
||||
<div :class="$style.emptyState" data-testid="agent-evals-empty-state">
|
||||
<div :class="$style.iconBadge">
|
||||
<N8nIcon icon="sparkles" size="xlarge" />
|
||||
</div>
|
||||
<N8nText tag="h3" size="large" color="text-dark" bold :class="$style.title">
|
||||
{{ i18n.baseText('agents.builder.agentEvals.empty.title') }}
|
||||
</N8nText>
|
||||
<N8nText size="medium" color="text-base" :class="$style.description">
|
||||
{{ i18n.baseText('agents.builder.agentEvals.empty.description') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="large"
|
||||
type="button"
|
||||
icon="sparkles"
|
||||
:disabled="disabled"
|
||||
:loading="generating"
|
||||
data-testid="agent-evals-generate-button"
|
||||
@click="emit('generate')"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.agentEvals.empty.generate') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--lg);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Vertical rhythm is a single uniform gap, matching the design — no per-child
|
||||
margins, so adding the case list later can't inherit odd spacing. */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--spacing--2xl) var(--spacing--lg);
|
||||
border: var(--border-width) dashed var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing--xs);
|
||||
color: var(--color--text);
|
||||
background-color: var(--background--active);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Caps the measure at the design's 400px so the copy wraps to three lines
|
||||
instead of stretching the full panel width. */
|
||||
.description {
|
||||
display: block;
|
||||
max-width: 25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { computed, nextTick, ref } from 'vue';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAgentBuilderMainTabs } from './useAgentBuilderMainTabs';
|
||||
import { EXECUTIONS_SECTION_KEY } from '../constants';
|
||||
|
||||
const route = { query: {} as Record<string, string | undefined> };
|
||||
const replace = vi.fn(async (to: { query: Record<string, string | undefined> }) => {
|
||||
// Mirror the router: `replace` is what makes the query the source of truth,
|
||||
// so the composable's own watcher has to see the new value.
|
||||
route.query = to.query;
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => route,
|
||||
useRouter: () => ({ replace }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({ baseText: (key: string) => `mocked-${key}` }),
|
||||
}));
|
||||
|
||||
const evalsFlag = ref(false);
|
||||
vi.mock('@/features/ai/evaluation.ee/composables/useAgentEvalsFlag', () => ({
|
||||
useAgentEvalsFlag: () => evalsFlag,
|
||||
}));
|
||||
|
||||
const setup = () => useAgentBuilderMainTabs({ executionsCount: computed(() => 0) });
|
||||
|
||||
const tabValues = () => setup().mainTabOptions.value.map((tab) => tab.value);
|
||||
|
||||
describe('useAgentBuilderMainTabs', () => {
|
||||
beforeEach(() => {
|
||||
route.query = {};
|
||||
evalsFlag.value = false;
|
||||
replace.mockClear();
|
||||
});
|
||||
|
||||
describe('flag gating', () => {
|
||||
it('omits the evals tab entirely when the flag is off', () => {
|
||||
expect(tabValues()).toEqual(['agent', 'knowledge', 'sessions', 'settings']);
|
||||
});
|
||||
|
||||
it('appends the evals tab after settings when the flag is on', () => {
|
||||
evalsFlag.value = true;
|
||||
|
||||
expect(tabValues()).toEqual(['agent', 'knowledge', 'sessions', 'settings', 'evals']);
|
||||
});
|
||||
|
||||
it('reacts to the flag resolving after the first frame', async () => {
|
||||
const { mainTabOptions } = setup();
|
||||
expect(mainTabOptions.value.map((tab) => tab.value)).not.toContain('evals');
|
||||
|
||||
// PostHog resolves asynchronously, so the tab row has to pick it up
|
||||
// rather than being computed once at setup.
|
||||
evalsFlag.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(mainTabOptions.value.map((tab) => tab.value)).toContain('evals');
|
||||
});
|
||||
});
|
||||
|
||||
describe('section query param', () => {
|
||||
it('round-trips evals through the query param like the sibling tabs', async () => {
|
||||
evalsFlag.value = true;
|
||||
const { activeMainTab } = setup();
|
||||
|
||||
activeMainTab.value = 'evals';
|
||||
await nextTick();
|
||||
|
||||
expect(replace).toHaveBeenCalledWith({ query: { section: 'evals' } });
|
||||
expect(activeMainTab.value).toBe('evals');
|
||||
});
|
||||
|
||||
it('selects the evals tab from a deep link when the flag is on', () => {
|
||||
evalsFlag.value = true;
|
||||
route.query = { section: 'evals' };
|
||||
|
||||
expect(setup().activeMainTab.value).toBe('evals');
|
||||
});
|
||||
|
||||
it('falls back to the agent tab for an evals deep link while the flag is off', () => {
|
||||
route.query = { section: 'evals' };
|
||||
|
||||
// Selecting a tab that is absent from the row would leave the row with
|
||||
// nothing highlighted, so an unreachable deep link collapses to default.
|
||||
expect(setup().activeMainTab.value).toBe('agent');
|
||||
});
|
||||
|
||||
it('honours an evals deep link once a late-resolving flag turns on', async () => {
|
||||
route.query = { section: 'evals' };
|
||||
const { activeMainTab } = setup();
|
||||
expect(activeMainTab.value).toBe('agent');
|
||||
|
||||
evalsFlag.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(activeMainTab.value).toBe('evals');
|
||||
});
|
||||
|
||||
it('leaves the sibling tabs unaffected by the flag', () => {
|
||||
route.query = { section: EXECUTIONS_SECTION_KEY };
|
||||
|
||||
expect(setup().activeMainTab.value).toBe('sessions');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,21 +4,31 @@ import { useRoute, useRouter } from 'vue-router';
|
|||
import type { LocationQueryValue } from 'vue-router';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
import { useAgentEvalsFlag } from '@/features/ai/evaluation.ee/composables/useAgentEvalsFlag';
|
||||
import { EXECUTIONS_SECTION_KEY } from '../constants';
|
||||
|
||||
export type AgentBuilderMainTab = 'agent' | 'knowledge' | 'sessions' | 'settings';
|
||||
export type AgentBuilderMainTab = 'agent' | 'knowledge' | 'sessions' | 'settings' | 'evals';
|
||||
|
||||
type AgentBuilderSection = 'knowledge' | typeof EXECUTIONS_SECTION_KEY | 'settings' | null;
|
||||
type AgentBuilderSection =
|
||||
| 'knowledge'
|
||||
| typeof EXECUTIONS_SECTION_KEY
|
||||
| 'settings'
|
||||
| 'evals'
|
||||
| null;
|
||||
|
||||
const SECTION_QUERY_PARAM = 'section';
|
||||
|
||||
function getSectionFromQuery(
|
||||
section: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
isEvalsEnabled: boolean,
|
||||
): AgentBuilderSection {
|
||||
const value = Array.isArray(section) ? section[0] : section;
|
||||
if (value === 'knowledge' || value === EXECUTIONS_SECTION_KEY || value === 'settings') {
|
||||
return value;
|
||||
}
|
||||
// A deep link to the evals surface falls back to the default tab while the
|
||||
// flag is off, rather than selecting a tab that isn't in the row.
|
||||
if (value === 'evals' && isEvalsEnabled) return 'evals';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +36,7 @@ function getSectionFromTab(tab: AgentBuilderMainTab): AgentBuilderSection {
|
|||
if (tab === 'knowledge') return 'knowledge';
|
||||
if (tab === 'sessions') return EXECUTIONS_SECTION_KEY;
|
||||
if (tab === 'settings') return 'settings';
|
||||
if (tab === 'evals') return 'evals';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +50,7 @@ export function useAgentBuilderMainTabs({
|
|||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const isEvalsEnabled = useAgentEvalsFlag();
|
||||
const selectedSection = ref<AgentBuilderSection>(null);
|
||||
|
||||
async function setSelectedSection(section: AgentBuilderSection) {
|
||||
|
|
@ -54,6 +66,7 @@ export function useAgentBuilderMainTabs({
|
|||
if (selectedSection.value === 'knowledge') return 'knowledge';
|
||||
if (selectedSection.value === EXECUTIONS_SECTION_KEY) return 'sessions';
|
||||
if (selectedSection.value === 'settings') return 'settings';
|
||||
if (selectedSection.value === 'evals') return 'evals';
|
||||
return 'agent';
|
||||
},
|
||||
set(tab) {
|
||||
|
|
@ -72,6 +85,16 @@ export function useAgentBuilderMainTabs({
|
|||
label: i18n.baseText('agents.builder.header.tab.settings' as BaseTextKey),
|
||||
value: 'settings',
|
||||
},
|
||||
// Absent rather than disabled while the flag is off — a disabled tab
|
||||
// advertises a surface the user has no way to reach.
|
||||
...(isEvalsEnabled.value
|
||||
? [
|
||||
{
|
||||
label: i18n.baseText('agents.builder.header.tab.agentEvals'),
|
||||
value: 'evals' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const executionsDescription = computed(() =>
|
||||
|
|
@ -81,11 +104,14 @@ export function useAgentBuilderMainTabs({
|
|||
}),
|
||||
);
|
||||
|
||||
// The flag is a watch source, not just read inside: PostHog resolves after the
|
||||
// first frame, so a `?section=evals` deep link has to be re-derived once the
|
||||
// flag arrives or it would stay collapsed to the default tab.
|
||||
watch(
|
||||
() => [routeBacked.value, route.query[SECTION_QUERY_PARAM]] as const,
|
||||
([isRouteBacked, section]) => {
|
||||
() => [routeBacked.value, route.query[SECTION_QUERY_PARAM], isEvalsEnabled.value] as const,
|
||||
([isRouteBacked, section, evalsEnabled]) => {
|
||||
if (!isRouteBacked) return;
|
||||
selectedSection.value = getSectionFromQuery(section);
|
||||
selectedSection.value = getSectionFromQuery(section, evalsEnabled);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import { useAgentConfig } from '../composables/useAgentConfig';
|
|||
import { useAgentConfigValidation } from '../composables/useAgentConfigValidation';
|
||||
import { useAgentPermissions } from '../composables/useAgentPermissions';
|
||||
import { useAgentSessionsStore } from '../agentSessions.store';
|
||||
import { useAgentEvalsStore } from '../agentEvals.store';
|
||||
import { useAgentBuilderSession } from '../composables/useAgentBuilderSession';
|
||||
import { useAgentConfigAutosave } from '../composables/useAgentConfigAutosave';
|
||||
import { useAgentBuilderMainTabs } from '../composables/useAgentBuilderMainTabs';
|
||||
|
|
@ -120,6 +121,7 @@ const instanceAiAvailable = useInstanceAiAvailable();
|
|||
const { canSendPreviewToInstanceAi, sendPreviewSessionToInstanceAi } =
|
||||
useInstanceAiAgentPreviewHandoff();
|
||||
const sessionsStore = useAgentSessionsStore();
|
||||
const agentEvalsStore = useAgentEvalsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
|
|
@ -340,6 +342,30 @@ function isStaleAgentTarget(targetProjectId: string, targetAgentId: string): boo
|
|||
return projectId.value !== targetProjectId || agentId.value !== targetAgentId;
|
||||
}
|
||||
|
||||
// Drafts cases from the agent's own config. The generated dataset isn't
|
||||
// rendered yet, so the toast is the only confirmation the user gets that the
|
||||
// work landed.
|
||||
async function onGenerateEvalCases() {
|
||||
const targetProjectId = projectId.value;
|
||||
const targetAgentId = agentId.value;
|
||||
if (!targetProjectId || !targetAgentId) return;
|
||||
|
||||
try {
|
||||
const { cases } = await agentEvalsStore.generateDraftCases(targetProjectId, targetAgentId);
|
||||
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return;
|
||||
showMessage({
|
||||
title: locale.baseText('agents.builder.agentEvals.generated', {
|
||||
adjustToNumber: cases.length,
|
||||
interpolate: { count: String(cases.length) },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return;
|
||||
showError(error, locale.baseText('agents.builder.agentEvals.generateError'));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgent(
|
||||
targetProjectId: string = projectId.value,
|
||||
targetAgentId: string = agentId.value,
|
||||
|
|
@ -1593,6 +1619,7 @@ function onPreviewBreadcrumbSelect(item: PathItem) {
|
|||
:agent-unsaved="isUnsaved"
|
||||
:ensure-agent-persisted="ensureAgentPersisted"
|
||||
:executions-description="executionsDescription"
|
||||
:generating-eval-cases="agentEvalsStore.isGeneratingCases(agentId)"
|
||||
:artifact-mode="isArtifactMode"
|
||||
:config-validation-issues="configValidation?.issues ?? []"
|
||||
@update:config="onConfigFieldUpdate"
|
||||
|
|
@ -1613,6 +1640,7 @@ function onPreviewBreadcrumbSelect(item: PathItem) {
|
|||
@toggle-mcp-access="onToggleMcpAccess"
|
||||
@tasks-changed="() => onConfigUpdated()"
|
||||
@agent-changed="refreshAgentAfterIntegrationChange"
|
||||
@generate-eval-cases="onGenerateEvalCases"
|
||||
/>
|
||||
|
||||
<AgentVersionHistoryPanel
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user