This commit is contained in:
Luka Zivkovic 2026-10-16 19:53:16 +02:00 committed by GitHub
commit 3a43aae0ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 538 additions and 53 deletions

View File

@ -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…",

View File

@ -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();
});
});

View File

@ -392,6 +392,7 @@ defineExpose({ onSetupTaskAction });
data-testid="agent-evals-tab-content"
>
<AgentEvalsSection
:config="localConfig"
:project-id="projectId"
:agent-id="agentId"
:agent-unsaved="agentUnsaved"

View File

@ -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;
}

View File

@ -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>

View File

@ -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

View File

@ -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)] },

View File

@ -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();
});

View File

@ -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 {

View File

@ -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"