mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge d01e521241 into 3d7164d7d2
This commit is contained in:
commit
3a43aae0ac
|
|
@ -7884,7 +7884,10 @@
|
|||
"instanceAi.testAgentPreview.generatingSuite": "Generating cases to your eval suite…",
|
||||
"instanceAi.testAgentPreview.yourTry": "Your try",
|
||||
"instanceAi.testAgentPreview.tryExpandedPlaceholder": "Placeholder — expanded content goes here",
|
||||
"instanceAi.testAgentPreview.savedAsFirstCheck": "Saved as your first check: an example you can run again, like a test.",
|
||||
"instanceAi.testAgentPreview.runningChecks": "Running {count} check, {done} done | Running {count} checks, {done} done",
|
||||
"instanceAi.testAgentPreview.openChecks": "Open Checks",
|
||||
"instanceAi.testAgentPreview.viewFirstCheck": "View",
|
||||
"instanceAi.testAgentPreview.hideFirstCheck": "Hide",
|
||||
"instanceAi.testAgentPreview.checkMoreExamples": "Harder cases to check",
|
||||
"instanceAi.testAgentPreview.examplesCount": "{count} of {total} checks",
|
||||
"instanceAi.testAgentPreview.toTry": "to try",
|
||||
|
|
@ -7893,7 +7896,6 @@
|
|||
"instanceAi.testAgentPreview.showMoreExamples": "+{count} more",
|
||||
"instanceAi.testAgentPreview.showFewerExamples": "Show fewer",
|
||||
"instanceAi.testAgentPreview.checkYourAgent": "Check your agent",
|
||||
"instanceAi.testAgentPreview.checkingLeft": "Checking, {count} left",
|
||||
"instanceAi.testAgentPreview.runProgressUnavailable": "Your checks are saved, but their progress could not be loaded.",
|
||||
"instanceAi.testAgentPreview.retryRun": "Try again",
|
||||
"instanceAi.testAgentPreview.wentWellNeedWork": "{passed} of {total} went well, {needsWork} need work",
|
||||
|
|
@ -9073,6 +9075,10 @@
|
|||
"agents.builder.agentEvals.empty.preview.addYourOwn": "Add your own",
|
||||
"agents.builder.agentEvals.generated": "{count} test case generated | {count} test cases generated",
|
||||
"agents.builder.agentEvals.generateError": "Couldn't generate test cases",
|
||||
"agents.builder.agentEvals.mockNotice.none": "Your agent won't really message anyone or change anything here.",
|
||||
"agents.builder.agentEvals.mockNotice.one": "Your agent won't really use {first} here.",
|
||||
"agents.builder.agentEvals.mockNotice.two": "Your agent won't really use {first} or {second} here.",
|
||||
"agents.builder.agentEvals.mockNotice.more": "Your agent won't really use {first}, {second} and {count} more here.",
|
||||
"agents.builder.agentEvals.run.finished": "{success} of {total} case passed | {success} of {total} cases passed",
|
||||
"agents.builder.agentEvals.run.startError": "Couldn't start the run",
|
||||
"agents.builder.agentEvals.run.starting": "Starting…",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import type { AgentJsonConfig, AgentJsonNodeToolConfig } from '@n8n/api-types';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import AgentEvalMockNotice from '../components/AgentEvalMockNotice.vue';
|
||||
|
||||
const { ensureLoaded } = vi.hoisted(() => ({ ensureLoaded: vi.fn() }));
|
||||
|
||||
vi.mock('@/app/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: () => ({
|
||||
getNodeType: (name: string) => ({
|
||||
displayName: (
|
||||
{ slack: 'Slack Tool', sheets: 'Google Sheets', gmail: 'Gmail' } as Record<string, string>
|
||||
)[name],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock('../composables/useAgentIntegrationsCatalog', () => ({
|
||||
useAgentIntegrationsCatalog: () => ({
|
||||
catalog: ref([
|
||||
{ type: 'slack', label: 'Slack' },
|
||||
{ type: 'telegram', label: 'Telegram' },
|
||||
]),
|
||||
ensureLoaded,
|
||||
}),
|
||||
}));
|
||||
const node = (nodeType: string): AgentJsonNodeToolConfig => ({
|
||||
type: 'node',
|
||||
name: nodeType,
|
||||
node: { nodeType, nodeTypeVersion: 1, nodeParameters: {} },
|
||||
});
|
||||
const renderComponent = createComponentRenderer(AgentEvalMockNotice);
|
||||
const config = (overrides: Partial<AgentJsonConfig> = {}): AgentJsonConfig => ({
|
||||
name: 'Agent',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
instructions: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AgentEvalMockNotice', () => {
|
||||
beforeEach(() => {
|
||||
ensureLoaded.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it.each([undefined, null])('shows the generic assurance before config loads', (agentConfig) => {
|
||||
const { getByText } = renderComponent({
|
||||
props: { config: agentConfig, projectId: 'project-1' },
|
||||
});
|
||||
|
||||
expect(
|
||||
getByText("Your agent won't really message anyone or change anything here."),
|
||||
).toBeInTheDocument();
|
||||
expect(ensureLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[], "Your agent won't really message anyone or change anything here."],
|
||||
[[node('slack')], "Your agent won't really use Slack here."],
|
||||
[[node('slack'), node('sheets')], "Your agent won't really use Slack or Google Sheets here."],
|
||||
[
|
||||
[node('slack'), node('sheets'), node('gmail')],
|
||||
"Your agent won't really use Slack, Google Sheets and 1 more here.",
|
||||
],
|
||||
] as const)('describes the configured services', (tools, expected) => {
|
||||
const { getByText } = renderComponent({
|
||||
props: { config: config({ tools: [...tools] }), projectId: 'project-1' },
|
||||
});
|
||||
expect(getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
it('deduplicates channels and node tools and excludes disabled tools', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
projectId: 'project-1',
|
||||
config: config({
|
||||
tools: [node('slack'), { ...node('sheets'), enabled: false }],
|
||||
integrations: [
|
||||
{ type: 'slack', credentialId: 'slack-1' },
|
||||
{ type: 'telegram', credentialId: 'telegram-1' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(getByText("Your agent won't really use Slack or Telegram here.")).toBeInTheDocument();
|
||||
});
|
||||
it('uses configured workflow and MCP names without showing credential ids', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
projectId: 'project-1',
|
||||
config: config({
|
||||
tools: [{ type: 'workflow', workflow: 'Order management', name: 'Create order' }],
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'CRM',
|
||||
url: 'https://example.com/mcp',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(getByText("Your agent won't really use Create order or CRM here.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the configured node name when node metadata has no display name', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
projectId: 'project-1',
|
||||
config: config({ tools: [{ ...node('community.inventory'), name: 'Inventory API' }] }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText("Your agent won't really use Inventory API here.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the workflow name and a readable custom tool id when no alias exists', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
projectId: 'project-1',
|
||||
config: config({
|
||||
tools: [
|
||||
{ type: 'workflow', workflow: 'Order management' },
|
||||
{ type: 'custom', id: 'send_invoice' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getByText("Your agent won't really use Order management or Send invoice here."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes built-in chat and channels without a connected credential', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
projectId: 'project-1',
|
||||
config: config({
|
||||
integrations: [
|
||||
{ type: 'n8n_chat', credentialId: '' },
|
||||
{ type: 'slack', credentialId: ' ' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getByText("Your agent won't really message anyone or change anything here."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the assurance readable when the integration catalog cannot load', async () => {
|
||||
ensureLoaded.mockRejectedValueOnce(new Error('Catalog unavailable'));
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
projectId: 'project-1',
|
||||
config: config({ integrations: [{ type: 'discord', credentialId: 'discord-1' }] }),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(ensureLoaded).toHaveBeenCalledWith('project-1');
|
||||
expect(getByText("Your agent won't really use Discord here.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -392,6 +392,7 @@ defineExpose({ onSetupTaskAction });
|
|||
data-testid="agent-evals-tab-content"
|
||||
>
|
||||
<AgentEvalsSection
|
||||
:config="localConfig"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:agent-unsaved="agentUnsaved"
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ defineExpose({ sliderValue, focusOwnInput });
|
|||
cursor: ew-resize;
|
||||
|
||||
:global(.el-slider__runway) {
|
||||
background-color: var(--run-data--color--background);
|
||||
background-color: var(--border-color--stronger);
|
||||
border: none;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
|
||||
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
|
||||
|
||||
const props = defineProps<{ config?: AgentJsonConfig | null; projectId: string }>();
|
||||
const i18n = useI18n();
|
||||
const nodeTypes = useNodeTypesStore();
|
||||
const { catalog, ensureLoaded } = useAgentIntegrationsCatalog();
|
||||
watch(
|
||||
() => [props.projectId, props.config?.integrations] as const,
|
||||
([projectId, integrations]) => {
|
||||
if (integrations?.length) void ensureLoaded(projectId).catch(() => {});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const services = computed(() => {
|
||||
const names: string[] = [];
|
||||
for (const tool of props.config?.tools ?? []) {
|
||||
if (tool.enabled === false) continue;
|
||||
if (tool.type === 'node') {
|
||||
const nodeType = nodeTypes.getNodeType(tool.node.nodeType, tool.node.nodeTypeVersion);
|
||||
names.push(nodeType?.displayName?.replace(/ Tool$/, '') ?? tool.name);
|
||||
} else if (tool.type === 'workflow') names.push(tool.name || tool.workflow);
|
||||
else names.push(formatToolNameForDisplay(tool.id));
|
||||
}
|
||||
for (const server of props.config?.mcpServers ?? []) names.push(server.name);
|
||||
for (const channel of props.config?.integrations ?? []) {
|
||||
if (channel.type === 'n8n_chat' || !channel.credentialId.trim()) continue;
|
||||
names.push(
|
||||
catalog.value?.find((entry) => entry.type === channel.type)?.label ??
|
||||
formatToolNameForDisplay(channel.type),
|
||||
);
|
||||
}
|
||||
return [...new Set(names.map((name) => name.trim()).filter(Boolean))];
|
||||
});
|
||||
const message = computed(() => {
|
||||
const [first, second] = services.value;
|
||||
if (!first) return i18n.baseText('agents.builder.agentEvals.mockNotice.none');
|
||||
if (!second)
|
||||
return i18n.baseText('agents.builder.agentEvals.mockNotice.one', { interpolate: { first } });
|
||||
if (services.value.length === 2)
|
||||
return i18n.baseText('agents.builder.agentEvals.mockNotice.two', {
|
||||
interpolate: { first, second },
|
||||
});
|
||||
return i18n.baseText('agents.builder.agentEvals.mockNotice.more', {
|
||||
interpolate: { first, second, count: String(services.value.length - 2) },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nText size="small" color="text-base" data-test-id="agent-eval-mock-notice">{{
|
||||
message
|
||||
}}</N8nText>
|
||||
</template>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
* run of the newest dataset itself rather than depending on a list view.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type { AgentEvalDraftCase } from '@n8n/api-types';
|
||||
import type { AgentEvalDraftCase, AgentJsonConfig } from '@n8n/api-types';
|
||||
import { N8nButton, N8nCallout, N8nIcon, N8nLoading, N8nText } from '@n8n/design-system';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -15,6 +15,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
import { useTestAgentPreviewFlag } from '@/features/agents/composables/useTestAgentPreviewFlag';
|
||||
import { useAgentEvalsStore } from '../agentEvals.store';
|
||||
import { isDataTableDataset, resolveCaseColumns } from '../utils/agentEvalCases.utils';
|
||||
import AgentEvalMockNotice from './AgentEvalMockNotice.vue';
|
||||
import AgentEvalCasesCard from './AgentEvalCasesCard.vue';
|
||||
import AgentEvalChecksPanel from './AgentEvalChecksPanel.vue';
|
||||
import AgentEvalResultsPanel from './AgentEvalResultsPanel.vue';
|
||||
|
|
@ -22,6 +23,7 @@ import AgentEvalsEmptyStatePreview from './AgentEvalsEmptyStatePreview.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
config?: AgentJsonConfig | null;
|
||||
agentId: string;
|
||||
/** No `agent:update` — cases render but nothing can be changed. */
|
||||
disabled?: boolean;
|
||||
|
|
@ -220,6 +222,7 @@ watch(() => props.agentId, load);
|
|||
|
||||
<template>
|
||||
<div :class="$style.section" data-testid="agent-evals-section">
|
||||
<AgentEvalMockNotice :config="config" :project-id="projectId" />
|
||||
<N8nLoading v-if="awaitingDatasets" :rows="4" data-testid="agent-evals-loading" />
|
||||
|
||||
<AgentEvalChecksPanel
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { fireEvent, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
|
@ -36,24 +36,34 @@ const renderComponent = createComponentRenderer(InstanceAiTestAgentExamplesPanel
|
|||
});
|
||||
|
||||
describe('InstanceAiTestAgentExamplesPanel', () => {
|
||||
it('shows the try input and a default slice of examples', () => {
|
||||
const { getByText, getAllByTestId } = renderComponent();
|
||||
it('shows a compact first-check summary and a default slice of examples', () => {
|
||||
const { getByText, queryByText, getAllByTestId } = renderComponent();
|
||||
|
||||
expect(getByText('Summarize the thread about the Acme SSO outage')).toBeInTheDocument();
|
||||
expect(getByText('First check passed')).toBeInTheDocument();
|
||||
expect(queryByText('Summarize the thread about the Acme SSO outage')).not.toBeInTheDocument();
|
||||
// Default slider value is 2.
|
||||
expect(getAllByTestId('instance-ai-test-agent-examples-example')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows a right chevron on the try row that emits open-case instead of expanding it', async () => {
|
||||
it('opens and closes the first check details with View', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId, queryByTestId, emitted } = renderComponent();
|
||||
|
||||
await user.click(getByTestId('instance-ai-test-agent-examples-try-toggle'));
|
||||
const { getByTestId, queryByText } = renderComponent();
|
||||
const button = getByTestId('instance-ai-test-agent-examples-view-first');
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(button);
|
||||
expect(queryByText('Summarize the thread about the Acme SSO outage')).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.click(button);
|
||||
expect(queryByText('Summarize the thread about the Acme SSO outage')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens Checks from the expanded first check without replacing the inline peek', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId, getByRole, emitted } = renderComponent();
|
||||
await user.click(getByTestId('instance-ai-test-agent-examples-view-first'));
|
||||
expect(emitted('open-case')).toBeUndefined();
|
||||
await user.click(getByRole('button', { name: 'Open Checks' }));
|
||||
expect(emitted('open-case')).toEqual([[null]]);
|
||||
expect(
|
||||
queryByTestId('instance-ai-test-agent-examples-try-placeholder'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits add-example with the typed text and clears the input', async () => {
|
||||
|
|
@ -111,14 +121,16 @@ describe('InstanceAiTestAgentExamplesPanel', () => {
|
|||
expect(emitted()['check-agent']).toEqual([[3]]);
|
||||
});
|
||||
|
||||
it('labels the confirmed try with its scenario tag', () => {
|
||||
const { getByText } = renderComponent();
|
||||
it('labels the expanded confirmed try with its scenario tag', async () => {
|
||||
const { getByText, getByTestId } = renderComponent();
|
||||
await fireEvent.click(getByTestId('instance-ai-test-agent-examples-view-first'));
|
||||
|
||||
expect(getByText('Upset')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to "Your try" when the confirmed try has no scenario', () => {
|
||||
const { getByText } = renderComponent({ props: { previewScenario: null } });
|
||||
it('falls back to "Your try" when the confirmed try has no scenario', async () => {
|
||||
const { getByText, getByTestId } = renderComponent({ props: { previewScenario: null } });
|
||||
await fireEvent.click(getByTestId('instance-ai-test-agent-examples-view-first'));
|
||||
|
||||
expect(getByText('Your try')).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -156,14 +168,49 @@ describe('InstanceAiTestAgentExamplesPanel', () => {
|
|||
},
|
||||
];
|
||||
|
||||
it('hides the confirmed try and shows how many cases are left', () => {
|
||||
const { getByText, queryByTestId, queryByText } = renderComponent({
|
||||
it('hides the confirmed try and shows the completed check count', () => {
|
||||
const { getByText, queryByTestId } = renderComponent({
|
||||
props: { caseRuns },
|
||||
});
|
||||
|
||||
expect(getByText('Checking, 1 left')).toBeInTheDocument();
|
||||
expect(getByText('Running 2 checks, 1 done')).toBeInTheDocument();
|
||||
expect(queryByTestId('instance-ai-test-agent-examples-try')).not.toBeInTheDocument();
|
||||
expect(queryByText(/Saved as your first check/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the singular count for a single running check', () => {
|
||||
const { getByText } = renderComponent({ props: { caseRuns: [caseRuns[1]] } });
|
||||
expect(getByText('Running 1 check, 0 done')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('limits the running summary to three avatars and shows the remaining count', () => {
|
||||
const fiveRuns = Array.from({ length: 5 }, (_, index) => ({
|
||||
...caseRuns[1],
|
||||
rowId: index + 1,
|
||||
}));
|
||||
const { getByText, getAllByTestId } = renderComponent({ props: { caseRuns: fiveRuns } });
|
||||
|
||||
const extraCount = getByText('+2');
|
||||
expect(extraCount.parentElement?.children).toHaveLength(4);
|
||||
expect(getByText('Running 5 checks, 0 done')).toBeInTheDocument();
|
||||
expect(getAllByTestId(/^instance-ai-test-agent-examples-case-\d+$/)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('updates elapsed time from the run start and removes the timer when settled', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-15T12:00:09Z'));
|
||||
try {
|
||||
const { getByTestId, queryByTestId, rerender, unmount } = renderComponent({
|
||||
props: { caseRuns, runStartedAt: '2026-10-15T12:00:00Z' },
|
||||
});
|
||||
expect(getByTestId('instance-ai-test-agent-examples-timer')).toHaveTextContent('0:09');
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(getByTestId('instance-ai-test-agent-examples-timer')).toHaveTextContent('0:11');
|
||||
await rerender({ caseRuns: caseRuns.map((run) => ({ ...run, status: 'pass' })) });
|
||||
expect(queryByTestId('instance-ai-test-agent-examples-timer')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces the progress line and Stop with a retry once the run could not start', async () => {
|
||||
|
|
@ -173,7 +220,7 @@ describe('InstanceAiTestAgentExamplesPanel', () => {
|
|||
});
|
||||
|
||||
expect(getByTestId('instance-ai-test-agent-examples-run-failed')).toBeInTheDocument();
|
||||
expect(queryByText(/Checking, \d+ left/)).not.toBeInTheDocument();
|
||||
expect(queryByText(/Running \d+ checks?, \d+ done/)).not.toBeInTheDocument();
|
||||
expect(queryByTestId('instance-ai-test-agent-examples-stop')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(getByTestId('instance-ai-test-agent-examples-retry-run'));
|
||||
|
|
@ -332,7 +379,7 @@ describe('InstanceAiTestAgentExamplesPanel', () => {
|
|||
await rerender({ caseRuns: null });
|
||||
|
||||
expect(queryByTestId('instance-ai-test-agent-examples-run-summary')).not.toBeInTheDocument();
|
||||
expect(getByTestId('instance-ai-test-agent-examples-try')).toBeInTheDocument();
|
||||
expect(getByTestId('instance-ai-test-agent-examples-first-summary')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -341,7 +388,7 @@ describe('InstanceAiTestAgentExamplesPanel', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(
|
||||
within(getByTestId('instance-ai-test-agent-examples-try')).getByRole('img', {
|
||||
within(getByTestId('instance-ai-test-agent-examples-first-summary')).getByRole('img', {
|
||||
name: /Passed/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -349,7 +396,7 @@ describe('InstanceAiTestAgentExamplesPanel', () => {
|
|||
|
||||
it('reflects the state it was graded in instead of always passing', () => {
|
||||
const { getByTestId } = renderComponent({ props: { previewStatus: 'work' as const } });
|
||||
const row = getByTestId('instance-ai-test-agent-examples-try');
|
||||
const row = getByTestId('instance-ai-test-agent-examples-first-summary');
|
||||
|
||||
expect(within(row).getByRole('img', { name: /Needs work/ })).toBeInTheDocument();
|
||||
expect(within(row).queryByRole('img', { name: /Passed/ })).not.toBeInTheDocument();
|
||||
|
|
@ -540,6 +587,28 @@ describe('InstanceAiTestAgentExamplesPanel', () => {
|
|||
expect(getByTestId(BUTTON)).toBeDisabled();
|
||||
});
|
||||
|
||||
it('clears the applying state when the request finishes so suggestions can be retried', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId, rerender, emitted } = renderComponent({
|
||||
props: { caseRuns: [failed(1), failed(2)] },
|
||||
});
|
||||
const button = getByTestId(BUTTON);
|
||||
|
||||
await user.click(button);
|
||||
await rerender({ applyingSuggestionIds: ['result-1', 'result-2'] });
|
||||
expect(button).toHaveAttribute('aria-busy', 'true');
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
await rerender({ applyingSuggestionIds: [] });
|
||||
expect(button).not.toHaveAttribute('aria-busy');
|
||||
expect(button).toBeEnabled();
|
||||
await user.click(button);
|
||||
expect(emitted('apply-suggestions')).toEqual([
|
||||
[['result-1', 'result-2']],
|
||||
[['result-1', 'result-2']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a queued apply-all click after a suggestion starts applying', async () => {
|
||||
const { getByTestId, emitted, rerender } = renderComponent({
|
||||
props: { caseRuns: [failed(1), failed(2)] },
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { AgentEvalVerdict } from '@n8n/api-types';
|
|||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import * as agentApi from '@/features/agents/composables/useAgentApi';
|
||||
import { agentsEventBus } from '@/features/agents/agents.eventBus';
|
||||
import { useAgentEvalsStore } from '@/features/agents/agentEvals.store';
|
||||
import type { AgentEvalDatasetRecord } from '@/features/agents/agentEvals.types';
|
||||
|
|
@ -160,6 +161,34 @@ describe('InstanceAiTestAgentPreviewPanel', () => {
|
|||
openAgentConfirmationModal.mockReset();
|
||||
});
|
||||
|
||||
it('clears the previous agent’s service names when the next config read fails', async () => {
|
||||
const store = useAgentEvalsStore();
|
||||
mockPreviewRun(store);
|
||||
const getConfig = vi
|
||||
.spyOn(agentApi, 'getAgentConfig')
|
||||
.mockResolvedValueOnce({
|
||||
config: {
|
||||
name: 'Agent',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
instructions: '',
|
||||
tools: [{ type: 'workflow', workflow: 'workflow-1', name: 'Slack' }],
|
||||
},
|
||||
configHash: 'first-config',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Unable to load config'));
|
||||
const { findByTestId, rerender } = renderComponent();
|
||||
const notice = await findByTestId('agent-eval-mock-notice');
|
||||
await waitFor(() =>
|
||||
expect(notice).toHaveTextContent("Your agent won't really use Slack here."),
|
||||
);
|
||||
|
||||
await rerender({ target: { agentId: 'agent-2', projectId: 'project-1' } });
|
||||
await waitFor(() => expect(getConfig).toHaveBeenCalledTimes(2));
|
||||
expect(notice).toHaveTextContent(
|
||||
"Your agent won't really message anyone or change anything here.",
|
||||
);
|
||||
});
|
||||
|
||||
it('runs a single case, then shows its message and the judge’s findings', async () => {
|
||||
const store = useAgentEvalsStore();
|
||||
mockPreviewRun(store);
|
||||
|
|
@ -283,6 +312,20 @@ describe('InstanceAiTestAgentPreviewPanel', () => {
|
|||
expect(answerCard.querySelector('[class*="content"]')).toHaveTextContent(/very long answer/);
|
||||
});
|
||||
|
||||
it('opens the Checks tab from the expanded first check', async () => {
|
||||
const store = useAgentEvalsStore();
|
||||
mockPreviewRun(store);
|
||||
vi.spyOn(store, 'generateDraftCases').mockResolvedValue({
|
||||
cases: [{ input: 'a', whatToCheck: 'b', scenario: 'Vague' }],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { findByTestId, getByRole, emitted } = renderComponent();
|
||||
await user.click(await findByTestId('instance-ai-test-agent-preview-check-harder'));
|
||||
await user.click(await findByTestId('instance-ai-test-agent-examples-view-first'));
|
||||
await user.click(getByRole('button', { name: 'Open Checks' }));
|
||||
expect(emitted('open-evals')).toEqual([[null]]);
|
||||
});
|
||||
|
||||
it('generates a batch of examples and shows the examples panel on "Check harder cases"', async () => {
|
||||
const store = useAgentEvalsStore();
|
||||
mockPreviewRun(store, { scenario: 'Upset' });
|
||||
|
|
@ -356,6 +399,7 @@ describe('InstanceAiTestAgentPreviewPanel', () => {
|
|||
expect(within(examples[0]).getByText('c')).toBeInTheDocument();
|
||||
expect(within(examples[1]).getByText('a')).toBeInTheDocument();
|
||||
// Each row labels itself with its own generated scenario tag.
|
||||
await user.click(getByTestId('instance-ai-test-agent-examples-view-first'));
|
||||
expect(getByText('Upset')).toBeInTheDocument();
|
||||
expect(within(examples[0]).getByText('Sensitive data')).toBeInTheDocument();
|
||||
expect(within(examples[1]).getByText('Vague')).toBeInTheDocument();
|
||||
|
|
@ -663,7 +707,7 @@ describe('InstanceAiTestAgentPreviewPanel', () => {
|
|||
await user.click(getByTestId('instance-ai-test-agent-examples-check-agent'));
|
||||
|
||||
// One of the two cases has settled — one is still left.
|
||||
expect(await findByText('Checking, 1 left')).toBeInTheDocument();
|
||||
expect(await findByText('Running 2 checks, 1 done')).toBeInTheDocument();
|
||||
expect(queryByTestId('instance-ai-test-agent-examples-try')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(getByTestId('instance-ai-test-agent-examples-case-2-toggle'));
|
||||
|
|
@ -1466,6 +1510,7 @@ describe('InstanceAiTestAgentPreviewPanel', () => {
|
|||
|
||||
await user.click(getByTestId(CHECK_HARDER));
|
||||
|
||||
await user.click(await findByTestId('instance-ai-test-agent-examples-view-first'));
|
||||
expect(await findByTestId('instance-ai-test-agent-examples-try')).toHaveTextContent('Upset');
|
||||
});
|
||||
|
||||
|
|
@ -1667,7 +1712,7 @@ describe('InstanceAiTestAgentPreviewPanel', () => {
|
|||
);
|
||||
await user.click(getByTestId('instance-ai-test-agent-preview-check-harder'));
|
||||
|
||||
const row = await findByTestId('instance-ai-test-agent-examples-try');
|
||||
const row = await findByTestId('instance-ai-test-agent-examples-first-summary');
|
||||
expect(within(row).getByRole('img', { name: /Needs work/ })).toBeInTheDocument();
|
||||
expect(within(row).queryByRole('img', { name: /Passed/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
* slider-controlled batch of additional generated examples the user can trim,
|
||||
* extend with their own, and hand off to a real check via "Check your agent".
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref, useId, watch } from 'vue';
|
||||
import { useIntervalFn } from '@vueuse/core';
|
||||
import { TIME } from '@/app/constants/durations';
|
||||
import { formatAgentElapsedTime } from '@/features/agents/utils/agent-elapsed-time';
|
||||
import type { AgentEvalDraftCase } from '@n8n/api-types';
|
||||
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -12,6 +15,7 @@ import type { ToolCall } from '@/features/ai/shared/agentsChat/types';
|
|||
import AgentAvatar, { type AgentAvatarKind } from '@/features/agents/components/AgentAvatar.vue';
|
||||
import AgentEvalExamplesSlider from '@/features/agents/components/AgentEvalExamplesSlider.vue';
|
||||
import AgentEvalSuggestionCard from '@/features/agents/components/AgentEvalSuggestionCard.vue';
|
||||
import EvalInitialSample from '@/features/agents/components/EvalInitialSample.vue';
|
||||
import AgentEvalTryRow from '@/features/agents/components/AgentEvalTryRow.vue';
|
||||
|
||||
/** One case of the running suite: its live status and, once settled, its answer. */
|
||||
|
|
@ -53,6 +57,7 @@ const props = defineProps<{
|
|||
caseRuns: SuiteCaseRun[] | null;
|
||||
/** True from the "Check your agent" click until the run has actually started. */
|
||||
startingRun?: boolean;
|
||||
runStartedAt?: string | null;
|
||||
/** True from the "Stop" click until the cancel request resolves. */
|
||||
stoppingRun?: boolean;
|
||||
/** The cases are saved but their run could not be started or followed. */
|
||||
|
|
@ -83,6 +88,8 @@ const i18n = useI18n();
|
|||
// Collapsed to the summary strip by default once the run settles — expanding
|
||||
// is the user's own request, not something a partial success should force.
|
||||
const summaryExpanded = ref(false);
|
||||
const firstCheckExpanded = ref(false);
|
||||
const firstCheckDetailsId = useId();
|
||||
|
||||
const waitingCount = computed(
|
||||
() => props.caseRuns?.filter((run) => run.status === 'waiting').length ?? 0,
|
||||
|
|
@ -98,6 +105,36 @@ const allPassed = computed(
|
|||
() => runSettled.value && needsWorkCount.value === 0 && passedCount.value > 0,
|
||||
);
|
||||
|
||||
const now = ref(Date.now());
|
||||
const localStartedAt = ref(now.value);
|
||||
const timerActive = computed(
|
||||
() => props.caseRuns !== null && !runSettled.value && !props.runFailed,
|
||||
);
|
||||
const { pause, resume } = useIntervalFn(
|
||||
() => {
|
||||
now.value = Date.now();
|
||||
},
|
||||
TIME.SECOND,
|
||||
{ immediate: false },
|
||||
);
|
||||
watch(
|
||||
timerActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
now.value = Date.now();
|
||||
localStartedAt.value = now.value;
|
||||
resume();
|
||||
} else pause();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const elapsed = computed(() => {
|
||||
const startedAt = Date.parse(props.runStartedAt ?? '');
|
||||
return formatAgentElapsedTime(
|
||||
now.value - (Number.isFinite(startedAt) ? startedAt : localStartedAt.value),
|
||||
);
|
||||
});
|
||||
|
||||
// "Keep as is" hides a card until a different suggestion arrives for that case.
|
||||
const dismissedSuggestions = ref<string[]>([]);
|
||||
const suggestionKey = (run: SuiteCaseRun) => `${run.rowId}:${run.fixSuggestion}`;
|
||||
|
|
@ -154,20 +191,53 @@ function onCheckYourAgent() {
|
|||
<template>
|
||||
<div :class="$style.root">
|
||||
<template v-if="!caseRuns">
|
||||
<AgentEvalTryRow
|
||||
:status="previewStatus ?? 'pass'"
|
||||
:input="previewInput"
|
||||
:output="previewOutput"
|
||||
:error-message="previewReasoning"
|
||||
:label="previewScenario ?? i18n.baseText('instanceAi.testAgentPreview.yourTry')"
|
||||
hide-revise
|
||||
test-id="instance-ai-test-agent-examples-try"
|
||||
@open="emit('open-case', null)"
|
||||
/>
|
||||
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('instanceAi.testAgentPreview.savedAsFirstCheck') }}
|
||||
</N8nText>
|
||||
<div
|
||||
:class="$style.firstCheckSummary"
|
||||
data-test-id="instance-ai-test-agent-examples-first-summary"
|
||||
>
|
||||
<AgentAvatar :kind="previewStatus ?? 'pass'" size="sm" />
|
||||
<N8nText color="text-dark">{{
|
||||
i18n.baseText(
|
||||
(previewStatus ?? 'pass') === 'pass'
|
||||
? 'instanceAi.testAgentPreview.firstCheckPassed'
|
||||
: 'instanceAi.testAgentPreview.firstCheckNeedsWork',
|
||||
)
|
||||
}}</N8nText>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:aria-expanded="firstCheckExpanded"
|
||||
:aria-controls="firstCheckDetailsId"
|
||||
data-test-id="instance-ai-test-agent-examples-view-first"
|
||||
@click="firstCheckExpanded = !firstCheckExpanded"
|
||||
>
|
||||
{{
|
||||
i18n.baseText(
|
||||
firstCheckExpanded
|
||||
? 'instanceAi.testAgentPreview.hideFirstCheck'
|
||||
: 'instanceAi.testAgentPreview.viewFirstCheck',
|
||||
)
|
||||
}}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="firstCheckExpanded"
|
||||
:id="firstCheckDetailsId"
|
||||
data-test-id="instance-ai-test-agent-examples-try"
|
||||
>
|
||||
<N8nText color="text-base" size="small">{{
|
||||
previewScenario ?? i18n.baseText('instanceAi.testAgentPreview.yourTry')
|
||||
}}</N8nText>
|
||||
<EvalInitialSample
|
||||
:preview-input="previewInput"
|
||||
:preview-output="previewOutput"
|
||||
:status="previewStatus ?? 'pass'"
|
||||
:error-message="previewReasoning ?? undefined"
|
||||
/>
|
||||
<N8nButton variant="ghost" size="small" @click="emit('open-case', null)">
|
||||
{{ i18n.baseText('instanceAi.testAgentPreview.openChecks') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<hr :class="$style.divider" />
|
||||
<N8nText color="text-dark" :class="$style.checkMoreExamplesHint">
|
||||
|
|
@ -200,13 +270,38 @@ function onCheckYourAgent() {
|
|||
>
|
||||
{{ i18n.baseText('instanceAi.testAgentPreview.runProgressUnavailable') }}
|
||||
</N8nText>
|
||||
<N8nText v-else-if="!runSettled" color="text-dark" :class="$style.runStatus">
|
||||
{{
|
||||
i18n.baseText('instanceAi.testAgentPreview.checkingLeft', {
|
||||
interpolate: { count: String(waitingCount) },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<div v-else-if="!runSettled" :class="$style.runningSummary">
|
||||
<div :class="$style.summaryAvatars" aria-hidden="true">
|
||||
<AgentAvatar
|
||||
v-for="run in caseRuns.slice(0, 3)"
|
||||
:key="run.rowId"
|
||||
:kind="run.status"
|
||||
size="row"
|
||||
:class="$style.summaryAvatar"
|
||||
/>
|
||||
<N8nText v-if="caseRuns.length > 3" color="text-base" :class="$style.extraCount"
|
||||
>+{{ caseRuns.length - 3 }}</N8nText
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<N8nText color="text-dark" :class="$style.runStatus">{{
|
||||
i18n.baseText('instanceAi.testAgentPreview.runningChecks', {
|
||||
adjustToNumber: caseRuns.length,
|
||||
interpolate: {
|
||||
count: String(caseRuns.length),
|
||||
done: String(caseRuns.length - waitingCount),
|
||||
},
|
||||
})
|
||||
}}</N8nText>
|
||||
<div
|
||||
:class="$style.timer"
|
||||
aria-live="off"
|
||||
data-test-id="instance-ai-test-agent-examples-timer"
|
||||
>
|
||||
{{ elapsed }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<N8nText
|
||||
v-else
|
||||
color="text-dark"
|
||||
|
|
@ -354,8 +449,30 @@ function onCheckYourAgent() {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.tryCard {
|
||||
width: 100%;
|
||||
.firstCheckSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.runningSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--sm);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--lg);
|
||||
}
|
||||
|
||||
.extraCount {
|
||||
margin-left: var(--spacing--2xs);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.timer {
|
||||
color: var(--text-color--subtle);
|
||||
font-size: var(--font-size--2xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.checkMoreExamplesHint {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* Runs and judges the first check with mocked tools before offering a full suite.
|
||||
* The preview is not persisted until the user saves the checks.
|
||||
*/
|
||||
import AgentEvalMockNotice from '@/features/agents/components/AgentEvalMockNotice.vue';
|
||||
import { useAgentConfig } from '@/features/agents/composables/useAgentConfig';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import type { AgentEvalDraftCase, AgentEvalVerdict } from '@n8n/api-types';
|
||||
import { N8nButton, N8nSpinner, N8nText, N8nIcon } from '@n8n/design-system';
|
||||
|
|
@ -52,6 +54,15 @@ const {
|
|||
|
||||
type Phase = 'generating-preview' | 'awaiting-confirmation' | 'generating-suite' | 'suite-ready';
|
||||
const phase = ref<Phase>('generating-preview');
|
||||
const { config, repoint, fetchConfig } = useAgentConfig();
|
||||
watch(
|
||||
() => props.target,
|
||||
({ projectId, agentId }) => {
|
||||
repoint(projectId, agentId);
|
||||
void fetchConfig(projectId, agentId).catch(() => {});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
// The drafted test message and the agent's real answer to it — set directly
|
||||
// from `previewRun`'s response, which already ran the agent and returned the
|
||||
// final text. No run id, no polling: unlike the suite below, this never
|
||||
|
|
@ -468,6 +479,7 @@ function onDontCreateEvals() {
|
|||
:data-passed="firstCheckPassed"
|
||||
data-test-id="instance-ai-test-agent-preview-first-check"
|
||||
>
|
||||
<AgentEvalMockNotice :config="config" :project-id="target.projectId" />
|
||||
<div :class="$style.firstCheckHeader">
|
||||
<AgentAvatar :kind="firstCheckPassed ? 'pass' : 'work'" size="sm" />
|
||||
<N8nText
|
||||
|
|
@ -614,6 +626,11 @@ function onDontCreateEvals() {
|
|||
:project-id="target.projectId"
|
||||
:examples="suiteCases"
|
||||
:case-runs="suiteCaseRuns"
|
||||
:run-started-at="
|
||||
suiteRunId && store.isRunInFlight(suiteRunId)
|
||||
? store.getReview(suiteRunId).run?.runAt
|
||||
: null
|
||||
"
|
||||
:starting-run="startingSuiteRun"
|
||||
:stopping-run="stoppingSuiteRun"
|
||||
:run-failed="suiteRunFailed"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user