mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 05:59:42 +02:00
feat(editor): Keep answered automation cards visible with their outcome (no-changelog)
An answered automation card stays in the chat and shows one line with the outcome and a link to the workflow. The line reads the propose_automation tool result when it arrives, and the tool step shows a summary of that result. The chat keeps the answers of this session, so a history refresh does not remove the answered card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gK4jZbBjL8toMAnrfJ4mh
This commit is contained in:
parent
c9c97502f8
commit
6f0f158619
|
|
@ -7965,6 +7965,36 @@
|
|||
"instanceAi.automation.action.saveWorkflow": "Save workflow",
|
||||
"instanceAi.automation.action.notNow": "Not now",
|
||||
"instanceAi.automation.toolStep": "Prepared an automation",
|
||||
"instanceAi.automation.resolved.on": "It's on. \"{title}\" runs {trigger} on {place}.",
|
||||
"instanceAi.automation.resolved.onNoTrigger": "It's on. \"{title}\" is now active.",
|
||||
"instanceAi.automation.resolved.changesLive": "Your changes are live. \"{title}\" runs {trigger} on {place}.",
|
||||
"instanceAi.automation.resolved.changesLiveNoTrigger": "Your changes to \"{title}\" are live.",
|
||||
"instanceAi.automation.resolved.turningOn": "Turning it on…",
|
||||
"instanceAi.automation.resolved.makingLive": "Making your changes live…",
|
||||
"instanceAi.automation.resolved.notOn": "Saved, but it couldn't be turned on. Open the workflow to check it.",
|
||||
"instanceAi.automation.resolved.notLive": "Saved, but your changes couldn't be made live. The earlier version keeps running. Open the workflow to check it.",
|
||||
"instanceAi.automation.resolved.saved": "Saved. \"{title}\" is off until you turn it on.",
|
||||
"instanceAi.automation.resolved.savedLive": "Saved. The live version of \"{title}\" keeps running.",
|
||||
"instanceAi.automation.resolved.savedManual": "Saved. \"{title}\" is in your workflows.",
|
||||
"instanceAi.automation.resolved.savedLocked": "Saved. \"{title}\" stays off until someone turns it on.",
|
||||
"instanceAi.automation.resolved.notSaved": "Not automated. \"{title}\" couldn't be saved.",
|
||||
"instanceAi.automation.resolved.failed": "Something went wrong with \"{title}\". Open the workflow to check it.",
|
||||
"instanceAi.automation.resolved.declined": "Not automated.",
|
||||
"instanceAi.automation.resolved.openWorkflow": "Open workflow",
|
||||
"instanceAi.automation.resolved.openWorkflowLabel": "Open workflow \"{title}\"",
|
||||
"instanceAi.automation.resolved.trigger.cron": "{description}",
|
||||
"instanceAi.automation.resolved.trigger.cronWithTimezone": "{description} ({timezone})",
|
||||
"instanceAi.automation.resolved.trigger.schedule": "at set times",
|
||||
"instanceAi.automation.resolved.trigger.webhook": "when a request arrives",
|
||||
"instanceAi.automation.resolved.trigger.form": "when someone sends the form",
|
||||
"instanceAi.automation.resolved.trigger.chat": "when someone sends a chat message",
|
||||
"instanceAi.automation.resolved.trigger.appEvent": "when an app event arrives",
|
||||
"instanceAi.automation.resolved.trigger.other": "when its trigger fires",
|
||||
"instanceAi.automation.summary.on": "It's on",
|
||||
"instanceAi.automation.summary.off": "Saved, switched off",
|
||||
"instanceAi.automation.summary.notOn": "Saved, couldn't turn it on",
|
||||
"instanceAi.automation.summary.notLive": "Saved, changes not live",
|
||||
"instanceAi.automation.summary.declined": "Not automated",
|
||||
"instanceAi.input.planReviewPlaceholder": "Ask for edits to the plan",
|
||||
"instanceAi.feedback.success": "Thanks for your feedback!",
|
||||
"instanceAi.message.actions.showDebugInfo": "Show debug information",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { makeProposal } from '@/features/ai/instanceAi/components/automation/__tests__/automationProposalFixtures';
|
||||
import { rebuildInteractiveFromHistory } from '@/features/ai/shared/agentsChat/messageMappers';
|
||||
import { keepSessionAnswers } from '@/features/ai/shared/agentsChat/resolvedCards';
|
||||
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
|
||||
import AgentChatMessageList from '../components/AgentChatMessageList.vue';
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
N8nButton: { template: '<button v-bind="$attrs"><slot /></button>' },
|
||||
N8nCallout: { template: '<div v-bind="$attrs"><slot /></div>' },
|
||||
N8nIcon: { template: '<i />' },
|
||||
N8nIconButton: { template: '<button v-bind="$attrs" />' },
|
||||
N8nText: { template: '<span v-bind="$attrs"><slot /></span>' },
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/AgentMarkdownChunk.vue', () => ({
|
||||
default: { template: '<div data-testid="markdown-chunk">{{ source }}</div>', props: ['source'] },
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/AgentChatToolSteps.vue', () => ({
|
||||
default: { template: '<div data-testid="tool-steps" />', props: ['toolCalls'] },
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/interactive/InteractiveCard.vue', () => ({
|
||||
default: {
|
||||
template: '<div data-testid="interactive-card" tabindex="-1">{{ payload.toolCallId }}</div>',
|
||||
props: ['payload'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/AgentChatMessageActions.vue', () => ({
|
||||
default: { template: '<div />', props: ['content'] },
|
||||
}));
|
||||
|
||||
/** The suspend payload of `propose_automation`, as the server sends it. */
|
||||
const automationSuspend = {
|
||||
requestId: 'req-auto',
|
||||
message: 'Want "Morning digest" to run automatically?',
|
||||
severity: 'info',
|
||||
capability: true,
|
||||
automationProposal: makeProposal(),
|
||||
};
|
||||
|
||||
const TURN_ON = { kind: 'capabilityDecision', approved: true, values: { activate: true } };
|
||||
const TOOL_RESULT = { workflowId: 'wf-1', url: '/workflow/wf-1', active: true, kept: true };
|
||||
|
||||
/** An answered call. Its output is the answer in the live chat, and the result after a reload. */
|
||||
function answeredCall(tool: string, suspendPayload: unknown, output: unknown): ToolCall {
|
||||
return { tool, toolCallId: `tc-${tool}`, state: 'done', suspendPayload, output, input: {} };
|
||||
}
|
||||
|
||||
/** The message as the chat builds it from its tool calls, live or from the history. */
|
||||
function messageOf(calls: ToolCall[]): ChatMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done.',
|
||||
toolCalls: calls,
|
||||
interactives: calls.flatMap((call) => rebuildInteractiveFromHistory(call) ?? []),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
function renderedCards(calls: ToolCall[]): string[] {
|
||||
const wrapper = mount(AgentChatMessageList, {
|
||||
props: { messages: [messageOf(calls)], messagingState: 'idle' },
|
||||
});
|
||||
return wrapper.findAll('[data-testid="interactive-card"]').map((card) => card.text());
|
||||
}
|
||||
|
||||
describe('AgentChatMessageList — answered Assistant cards', () => {
|
||||
it('keeps an automation card that the user answered in this session', () => {
|
||||
const call = answeredCall('propose_automation', automationSuspend, TURN_ON);
|
||||
|
||||
expect(renderedCards([call])).toEqual(['tc-propose_automation']);
|
||||
});
|
||||
|
||||
// In a live session, the chat puts the answer back after each history read (keepSessionAnswers).
|
||||
it('hides the automation card after a reload, when its value is the tool result', () => {
|
||||
const call = answeredCall('propose_automation', automationSuspend, TOOL_RESULT);
|
||||
|
||||
expect(renderedCards([call])).toEqual([]);
|
||||
});
|
||||
|
||||
it('still hides other answered Assistant cards, also capability cards', () => {
|
||||
const questions = answeredCall(
|
||||
'ask-user',
|
||||
{ requestId: 'req-q', message: 'Which one?', inputType: 'questions', questions: [] },
|
||||
{ kind: 'questions', answers: [] },
|
||||
);
|
||||
const capability = answeredCall(
|
||||
'executions',
|
||||
{ requestId: 'req-run', message: 'Run it?', severity: 'info', capability: true },
|
||||
{ kind: 'capabilityDecision', approved: true },
|
||||
);
|
||||
|
||||
expect(renderedCards([questions, capability])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the same card, and its focus, when the history is read again after the turn', async () => {
|
||||
const wrapper = mount(AgentChatMessageList, {
|
||||
props: {
|
||||
messages: [messageOf([answeredCall('propose_automation', automationSuspend, TURN_ON)])],
|
||||
messagingState: 'idle',
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const card = wrapper.get('[data-testid="interactive-card"]').element;
|
||||
if (!(card instanceof HTMLElement)) throw new Error('The card is not an element');
|
||||
card.focus();
|
||||
|
||||
// The refresh builds new messages with the tool result; the chat puts the answer back.
|
||||
const refreshed = messageOf([
|
||||
answeredCall('propose_automation', automationSuspend, TOOL_RESULT),
|
||||
]);
|
||||
await wrapper.setProps({
|
||||
messages: keepSessionAnswers([refreshed], new Map([['tc-propose_automation', TURN_ON]])),
|
||||
});
|
||||
|
||||
expect(wrapper.get('[data-testid="interactive-card"]').element).toBe(card);
|
||||
expect(document.activeElement).toBe(card);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { flushPromises } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { makeProposal } from '@/features/ai/instanceAi/components/automation/__tests__/automationProposalFixtures';
|
||||
import { ASSISTANT_CONFIRMATION_TOOL_NAME } from '@/features/ai/shared/agentsChat/assistantConfirmation';
|
||||
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
|
||||
import InteractiveCard from '../components/interactive/InteractiveCard.vue';
|
||||
// Load the on-demand card up front, so that the test does not wait for its first transform.
|
||||
import '@/features/ai/instanceAi/sharing/SharedThreadCard.vue';
|
||||
|
||||
const renderCard = createComponentRenderer(InteractiveCard, { pinia: createTestingPinia() });
|
||||
|
||||
function automationPayload(overrides: Partial<InteractivePayload> = {}): InteractivePayload {
|
||||
return {
|
||||
toolName: ASSISTANT_CONFIRMATION_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-1',
|
||||
input: {
|
||||
requestId: 'r-1',
|
||||
message: 'Want "Morning digest" to run automatically?',
|
||||
capability: true,
|
||||
automationProposal: makeProposal(),
|
||||
},
|
||||
...overrides,
|
||||
} as InteractivePayload;
|
||||
}
|
||||
|
||||
// The Assistant card loads on demand, through the wrapper for shared chats.
|
||||
describe('InteractiveCard with an automation card', () => {
|
||||
it('hands the answer of a resolved card to the Assistant card, which shows the outcome', async () => {
|
||||
const { findByTestId, queryByTestId } = renderCard({
|
||||
props: {
|
||||
payload: automationPayload({
|
||||
resolvedAt: 1,
|
||||
resolvedValue: { kind: 'capabilityDecision', approved: false },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(await findByTestId('automation-proposal-resolved')).toHaveTextContent('Not automated.');
|
||||
expect(queryByTestId('automation-proposal-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the open card while the card has no answer', async () => {
|
||||
const { findByTestId, queryByTestId } = renderCard({ props: { payload: automationPayload() } });
|
||||
await flushPromises();
|
||||
|
||||
expect(await findByTestId('automation-proposal-card')).toBeInTheDocument();
|
||||
expect(queryByTestId('automation-proposal-resolved')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -108,3 +108,39 @@ describe('summariseToolCall — n8n_chat_action', () => {
|
|||
expect(summariseToolCall(N8N_CHAT_ACTION_TOOL_NAME, { ok: true }, cardInput)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summariseToolCall — propose_automation', () => {
|
||||
const kept = { workflowId: 'wf-1', url: 'http://localhost:5678/workflow/wf-1', kept: true };
|
||||
const toolInput = { workflowId: 'wf-1', title: 'Morning digest', why: ['Every weekday'] };
|
||||
|
||||
it.each([
|
||||
['a workflow that is on', { ...kept, active: true }, "It's on"],
|
||||
['a saved workflow that is off', { ...kept, active: false }, 'Saved, switched off'],
|
||||
['a declined or blocked answer', { denied: true, message: 'Declined' }, 'Not automated'],
|
||||
[
|
||||
'a workflow that could not be turned on',
|
||||
{ ...kept, active: false, error: 'Saved, but could not turn it on' },
|
||||
"Saved, couldn't turn it on",
|
||||
],
|
||||
])('shows the outcome of %s from the tool result', (_name, output, label) => {
|
||||
expect(summariseToolCall('propose_automation', output, toolInput)).toBe(label);
|
||||
});
|
||||
|
||||
it('shows nothing while the output is the answer, before the result arrives', () => {
|
||||
const answer = { kind: 'capabilityDecision', approved: true, values: { activate: true } };
|
||||
|
||||
expect(summariseToolCall('propose_automation', answer, toolInput)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never says it is on when the result does not say active', () => {
|
||||
expect(summariseToolCall('propose_automation', { active: true }, toolInput)).toBeUndefined();
|
||||
expect(
|
||||
summariseToolCall('propose_automation', { ...kept, active: 'true' }, toolInput),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps other tools without a summary for the same output', () => {
|
||||
expect(summariseToolCall('build-workflow', { ...kept, active: true })).toBeUndefined();
|
||||
expect(summariseToolCall('search_nodes', { denied: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, ref } from 'vue';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import type { AgentChatMessagesResponse, AgentSseEvent } from '@n8n/api-types';
|
||||
|
||||
import { makeProposal } from '@/features/ai/instanceAi/components/automation/__tests__/automationProposalFixtures';
|
||||
import { getMessageInteractive } from '@/features/ai/shared/agentsChat/messageMappers';
|
||||
import { keepsResolvedCard } from '@/features/ai/shared/agentsChat/resolvedCards';
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const { getChatMessages, pushListeners } = vi.hoisted(() => ({
|
||||
getChatMessages: vi.fn(),
|
||||
pushListeners: [] as Array<(event: unknown) => void>,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError: vi.fn() }) }));
|
||||
|
||||
vi.mock('@/app/stores/pushConnection.store', () => ({
|
||||
usePushConnectionStore: () => ({
|
||||
pushConnect: vi.fn(),
|
||||
isConnected: true,
|
||||
addEventListener: (handler: (event: unknown) => void) => {
|
||||
pushListeners.push(handler);
|
||||
return () => pushListeners.splice(pushListeners.indexOf(handler), 1);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentApi', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../composables/useAgentApi')>()),
|
||||
getAgentChatQueue: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getChatMessages,
|
||||
}));
|
||||
|
||||
import { useAgentChatStream } from '../composables/useAgentChatStream';
|
||||
|
||||
/** The suspend payload of `propose_automation`, as the server sends it. */
|
||||
const SUSPEND = {
|
||||
requestId: 'req-auto',
|
||||
message: 'Want "Morning digest" to run automatically?',
|
||||
severity: 'info',
|
||||
capability: true,
|
||||
automationProposal: makeProposal(),
|
||||
};
|
||||
const TOOL_CALL = {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: 'tc-auto',
|
||||
toolName: 'propose_automation',
|
||||
input: { workflowId: 'wf-1' },
|
||||
suspendPayload: SUSPEND,
|
||||
};
|
||||
const TURN_ON = { kind: 'capabilityDecision', approved: true, values: { activate: true } };
|
||||
const SAVE = { kind: 'capabilityDecision', approved: true, values: { activate: false } };
|
||||
const TOOL_RESULT = { workflowId: 'wf-1', url: '/workflow/wf-1', active: true, kept: true };
|
||||
const RESUME = { runId: 'run-1', toolCallId: 'tc-auto', resumeData: TURN_ON };
|
||||
|
||||
/** The history of the chat: the card waits for an answer. */
|
||||
const WAITING: AgentChatMessagesResponse = {
|
||||
messages: [{ id: 'msg-1', role: 'assistant', executionStatus: 'success', content: [TOOL_CALL] }],
|
||||
openSuspensions: [{ toolCallId: 'tc-auto', runId: 'run-1', suspendPayload: SUSPEND }],
|
||||
activeExecutionId: null,
|
||||
};
|
||||
|
||||
/** The history after the turn: the server stores the tool result, not the answer. */
|
||||
function answered(output: unknown): AgentChatMessagesResponse {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
executionStatus: 'success',
|
||||
content: [{ ...TOOL_CALL, state: 'resolved', output }],
|
||||
},
|
||||
],
|
||||
openSuspensions: [],
|
||||
activeExecutionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sseResponse(events: AgentSseEvent[]): Response {
|
||||
const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('');
|
||||
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
||||
}
|
||||
|
||||
const TURN: AgentSseEvent[] = [
|
||||
{ type: 'execution-started', executionId: 'exec-2', sessionId: 'thread-1' },
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'tc-auto',
|
||||
toolName: 'propose_automation',
|
||||
output: TOOL_RESULT,
|
||||
},
|
||||
{ type: 'done', executionId: 'exec-2' },
|
||||
];
|
||||
|
||||
const scopes: Array<ReturnType<typeof effectScope>> = [];
|
||||
|
||||
async function openChat() {
|
||||
const scope = effectScope();
|
||||
scopes.push(scope);
|
||||
const hook = scope.run(() =>
|
||||
useAgentChatStream({
|
||||
projectId: ref('p1'),
|
||||
agentId: ref('a1'),
|
||||
continueSessionId: ref('thread-1'),
|
||||
}),
|
||||
);
|
||||
if (!hook) throw new Error('The effect scope is not active');
|
||||
await hook.loadHistory();
|
||||
return hook;
|
||||
}
|
||||
|
||||
function automationCard(hook: Awaited<ReturnType<typeof openChat>>) {
|
||||
const message = hook.messages.value.find((entry) => entry.id === 'msg-1');
|
||||
const card = message && getMessageInteractive(message, 'tc-auto');
|
||||
if (!card) throw new Error('The automation card is not in the chat');
|
||||
return card;
|
||||
}
|
||||
|
||||
function pushExecutionUpdate() {
|
||||
for (const listener of [...pushListeners]) {
|
||||
listener({
|
||||
type: 'agentExecutionUpdated',
|
||||
data: { projectId: 'p1', agentId: 'a1', threadId: 'thread-1', executionId: 'exec-2' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAgentChatStream — answers of this session', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', { getItem: vi.fn(() => '') });
|
||||
fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
getChatMessages.mockResolvedValueOnce(WAITING).mockResolvedValue(answered(TOOL_RESULT));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const scope of scopes.splice(0)) scope.stop();
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
getChatMessages.mockReset();
|
||||
});
|
||||
|
||||
it('keeps an answered automation card through the history reads after the turn', async () => {
|
||||
fetchMock.mockResolvedValue(sseResponse(TURN));
|
||||
const hook = await openChat();
|
||||
expect(automationCard(hook).resolvedAt).toBeUndefined();
|
||||
|
||||
await hook.resume(RESUME);
|
||||
await flushPromises();
|
||||
|
||||
// The stream end read the history again: the step holds the result, the card the answer.
|
||||
expect(getChatMessages).toHaveBeenCalledTimes(2);
|
||||
expect(hook.messages.value[0].toolCalls?.[0].output).toEqual(TOOL_RESULT);
|
||||
expect(automationCard(hook).resolvedValue).toEqual(TURN_ON);
|
||||
expect(keepsResolvedCard(automationCard(hook))).toBe(true);
|
||||
|
||||
pushExecutionUpdate();
|
||||
await flushPromises();
|
||||
|
||||
expect(getChatMessages).toHaveBeenCalledTimes(3);
|
||||
expect(automationCard(hook).resolvedValue).toEqual(TURN_ON);
|
||||
expect(keepsResolvedCard(automationCard(hook))).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the card after a reload, when no answer of this session is known', async () => {
|
||||
getChatMessages.mockReset().mockResolvedValue(answered(TOOL_RESULT));
|
||||
const hook = await openChat();
|
||||
|
||||
expect(automationCard(hook).resolvedValue).toEqual(TOOL_RESULT);
|
||||
expect(keepsResolvedCard(automationCard(hook))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not put back an answer that the server refused', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 409, message: 'This request was already answered' }), {
|
||||
status: 409,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
// Someone else answered first, with "Save".
|
||||
getChatMessages
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(WAITING)
|
||||
.mockResolvedValue(answered({ ...TOOL_RESULT, active: false }));
|
||||
const hook = await openChat();
|
||||
|
||||
await hook.resume(RESUME);
|
||||
await flushPromises();
|
||||
pushExecutionUpdate();
|
||||
await flushPromises();
|
||||
|
||||
expect(automationCard(hook).resolvedValue).toEqual({ ...TOOL_RESULT, active: false });
|
||||
expect(keepsResolvedCard(automationCard(hook))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['with a start event', TURN.slice(0, 2)],
|
||||
// The n8n Assistant streams the turn of an answer without a start event.
|
||||
['without a start event', TURN.slice(1, 2)],
|
||||
])(
|
||||
'keeps the answer when the turn fails after the tool ran (%s)',
|
||||
async (_name, before: AgentSseEvent[]) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([...before, { type: 'error', message: 'The model provider failed' }]),
|
||||
);
|
||||
const hook = await openChat();
|
||||
|
||||
await hook.resume(RESUME);
|
||||
await flushPromises();
|
||||
|
||||
expect(hook.messages.value[0].toolCalls?.[0].output).toEqual(TOOL_RESULT);
|
||||
expect(automationCard(hook).resolvedValue).toEqual(TURN_ON);
|
||||
expect(keepsResolvedCard(automationCard(hook))).toBe(true);
|
||||
|
||||
pushExecutionUpdate();
|
||||
await flushPromises();
|
||||
|
||||
expect(automationCard(hook).resolvedValue).toEqual(TURN_ON);
|
||||
expect(keepsResolvedCard(automationCard(hook))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not put back an answer when the stream fails before the server takes it', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([{ type: 'error', message: 'This action is no longer waiting for input' }]),
|
||||
);
|
||||
// Someone else answered first, with "Save".
|
||||
getChatMessages
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(WAITING)
|
||||
.mockResolvedValue(answered({ ...TOOL_RESULT, active: false }));
|
||||
const hook = await openChat();
|
||||
|
||||
await hook.resume(RESUME);
|
||||
await flushPromises();
|
||||
pushExecutionUpdate();
|
||||
await flushPromises();
|
||||
|
||||
expect(automationCard(hook).resolvedValue).toEqual({ ...TOOL_RESULT, active: false });
|
||||
expect(keepsResolvedCard(automationCard(hook))).toBe(false);
|
||||
});
|
||||
|
||||
it('opens the card again when the history says that it still waits', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([{ type: 'error', message: 'This action is no longer waiting for input' }]),
|
||||
);
|
||||
getChatMessages.mockReset().mockResolvedValue(WAITING);
|
||||
const hook = await openChat();
|
||||
|
||||
await hook.resume(RESUME);
|
||||
await flushPromises();
|
||||
|
||||
expect(automationCard(hook).resolvedAt).toBeUndefined();
|
||||
expect(automationCard(hook).resolvedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the latest answer when the user answers the same card again', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 503, statusText: 'Busy' }))
|
||||
.mockResolvedValue(sseResponse(TURN));
|
||||
getChatMessages
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(WAITING)
|
||||
.mockResolvedValueOnce(WAITING)
|
||||
.mockResolvedValue(answered({ ...TOOL_RESULT, active: false }));
|
||||
const hook = await openChat();
|
||||
|
||||
await hook.resume(RESUME);
|
||||
await flushPromises();
|
||||
await hook.resume({ ...RESUME, resumeData: SAVE });
|
||||
await flushPromises();
|
||||
|
||||
expect(automationCard(hook).resolvedValue).toEqual(SAVE);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import { useSessionStorage } from '@vueuse/core';
|
|||
import { TIME } from '@/app/constants/durations';
|
||||
import { isAssistantGroup, type DisplayGroup } from '@/features/ai/shared/agentsChat/displayGroups';
|
||||
import { getMessageInteractives, isRecord } from '@/features/ai/shared/agentsChat/messageMappers';
|
||||
import { keepsResolvedCard } from '@/features/ai/shared/agentsChat/resolvedCards';
|
||||
import {
|
||||
getMessageThinkingSegments,
|
||||
getThinkingDurationSec,
|
||||
|
|
@ -92,11 +93,13 @@ function externalWaitPlatform(tc: ToolCall): string | undefined {
|
|||
|
||||
/**
|
||||
* Tool approvals replace the composer. Answered chat cards collapse into
|
||||
* their tool-step summary. Display-only cards remain in the conversation.
|
||||
* their tool-step summary. Display-only cards remain in the conversation, and
|
||||
* so does an answered automation card, to show what happened.
|
||||
*/
|
||||
function shouldRenderInteractive(payload: InteractivePayload): boolean {
|
||||
if (payload.toolName === APPROVAL_TOOL_NAME) return false;
|
||||
if (!payload.resolvedAt) return !!payload.runId;
|
||||
if (keepsResolvedCard(payload)) return true;
|
||||
return payload.toolName === N8N_CHAT_ACTION_TOOL_NAME && !isAwaitingCard(payload.input.card);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ const interactiveRenderers = [
|
|||
return {
|
||||
input: payload.input,
|
||||
call: payload.call,
|
||||
// An answered automation card stays and reads the result of its tool call.
|
||||
resolvedValue: payload.resolvedValue,
|
||||
toolCallId: payload.toolCallId,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
startsNewSegment,
|
||||
type MessageSegmentKind,
|
||||
} from '@/features/ai/shared/agentsChat/messageSegments';
|
||||
import { keepSessionAnswers } from '@/features/ai/shared/agentsChat/resolvedCards';
|
||||
import { getMessageThinkingSegments } from '@/features/ai/shared/agentsChat/thinking';
|
||||
import type { ChatMessage, ThinkingSegment, ToolCall } from '@/features/ai/shared/agentsChat/types';
|
||||
import { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE } from '../constants';
|
||||
|
|
@ -209,6 +210,9 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
*/
|
||||
const warnings = ref<AgentChatWarning[]>([]);
|
||||
const dismissedWarningKeys = new Set<string>();
|
||||
// Card answers of this session, by tool call. A history refresh puts the tool result in their
|
||||
// place, and an answered card that stays in the chat needs its answer.
|
||||
const sessionAnswers = new Map<string, unknown>();
|
||||
|
||||
const messagingState = computed<'idle' | 'waitingFirstChunk' | 'receiving'>(() => {
|
||||
if (!isStreaming.value) return 'idle';
|
||||
|
|
@ -260,9 +264,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
retryCount = 0;
|
||||
clearTimeout(retryTimer);
|
||||
if (!isStreamOpen.value && streamAtStart === streamVersion) {
|
||||
messages.value = restoreBudgetNotices(
|
||||
applyOpenSuspensions(convertDbMessages(dbMessages), openSuspensions),
|
||||
);
|
||||
const history = applyOpenSuspensions(convertDbMessages(dbMessages), openSuspensions);
|
||||
messages.value = restoreBudgetNotices(keepSessionAnswers(history, sessionAnswers));
|
||||
isRecovering.value = false;
|
||||
if (runningExecutionId !== undefined) activeExecutionId.value = runningExecutionId;
|
||||
if (isCancelling.value) reconcileStop();
|
||||
|
|
@ -591,6 +594,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
terminalEventReceived: boolean;
|
||||
/** Set when the error text is the server's reason for a refusal. It can name users. */
|
||||
serverRefusal?: boolean;
|
||||
/** Set by the first event that is not an error: the server took the request. */
|
||||
started?: boolean;
|
||||
/**
|
||||
* Cursor pointing at the ChatMessage currently being filled by
|
||||
* text/reasoning/tool-input events. `start-step` / `finish-step`
|
||||
|
|
@ -1327,6 +1332,11 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
return { done: true };
|
||||
}
|
||||
|
||||
/** The first event that is not an error shows that the server took the request. */
|
||||
function noteStart(session: StreamSession, event: AgentSseEvent): void {
|
||||
if (event.type !== 'error') session.started = true;
|
||||
}
|
||||
|
||||
async function consumeStream(
|
||||
response: Response,
|
||||
session: StreamSession,
|
||||
|
|
@ -1354,6 +1364,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
} catch {
|
||||
continue;
|
||||
}
|
||||
noteStart(session, event);
|
||||
const result =
|
||||
event.type === 'execution-started' && session.needsStartValidation
|
||||
? await handleDelayedExecutionStart(event, session)
|
||||
|
|
@ -1386,7 +1397,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
}
|
||||
|
||||
type StreamOutcome = 'completed' | 'failed' | 'aborted' | 'busy' | 'detached';
|
||||
type PostResult = { outcome: StreamOutcome; rejection?: ChatRejection };
|
||||
/** `started` tells a turn that failed after the server took the request from a refusal. */
|
||||
type PostResult = { outcome: StreamOutcome; rejection?: ChatRejection; started?: boolean };
|
||||
|
||||
async function postAndConsume(
|
||||
url: string,
|
||||
|
|
@ -1447,10 +1459,10 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
}
|
||||
finalizeStream(session);
|
||||
if (!session.errorEmitted) session.onAccepted?.();
|
||||
return { outcome: session.errorEmitted ? 'failed' : 'completed' };
|
||||
return { outcome: session.errorEmitted ? 'failed' : 'completed', started: session.started };
|
||||
} catch (error) {
|
||||
if (!isCurrent() || controller.signal.aborted) return { outcome: 'aborted' };
|
||||
if (session.errorEmitted) return { outcome: 'failed' };
|
||||
if (session.errorEmitted) return { outcome: 'failed', started: session.started };
|
||||
if (session.userMessage && !session.queueId && !session.executionId)
|
||||
showError(error, locale.baseText('agents.chat.queue.sendError'));
|
||||
// A lost response cannot tell us whether the server accepted or finished the turn.
|
||||
|
|
@ -1641,11 +1653,13 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
|
||||
const { baseUrl } = rootStore.restApiContext;
|
||||
const url = `${baseUrl}/projects/${params.projectId.value}/agents/v2/${params.agentId.value}/chat/resume`;
|
||||
const { outcome, rejection } = await postAndConsume(
|
||||
rememberAnswer(payload);
|
||||
const { outcome, rejection, started } = await postAndConsume(
|
||||
url,
|
||||
{ runId: payload.runId, toolCallId: payload.toolCallId, resumeData },
|
||||
onAccepted,
|
||||
);
|
||||
forgetRefusedAnswer(payload, outcome, started);
|
||||
let reconciled = false;
|
||||
if (outcome === 'failed' || outcome === 'busy') {
|
||||
reconciled = await refreshHistory();
|
||||
|
|
@ -1679,6 +1693,21 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
return outcome === 'busy' ? 'busy' : 'sent';
|
||||
}
|
||||
|
||||
/** Keeps a card answer for later history reads. A steering message is not an answer. */
|
||||
function rememberAnswer(payload: ResumePayload): void {
|
||||
if (!('cancelled' in payload)) sessionAnswers.set(payload.toolCallId, payload.resumeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server did not take this answer, so the history must not show it. A turn that fails
|
||||
* after the server took the answer (for example a model error after the tool ran) keeps it.
|
||||
*/
|
||||
function forgetRefusedAnswer(payload: ResumePayload, outcome: StreamOutcome, started = false) {
|
||||
if (outcome === 'busy' || (outcome === 'failed' && !started)) {
|
||||
sessionAnswers.delete(payload.toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed card answer. A steering message that cancels a card is not an answer. */
|
||||
function reportResumeFailure(
|
||||
payload: ResumePayload,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { fireEvent } from '@testing-library/vue';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import InstanceAiConfirmationCard from '../agentsChat/InstanceAiConfirmationCard.vue';
|
||||
import { createTestRouter } from '../../navigation/__tests__/navigationFixtures';
|
||||
import { makeProposal } from '../automation/__tests__/automationProposalFixtures';
|
||||
|
||||
const push = vi.hoisted(() => ({
|
||||
|
|
@ -240,6 +241,62 @@ describe('InstanceAiConfirmationCard', () => {
|
|||
expect(getByTestId('automation-proposal-not-now')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the outcome of an answered automation card in place of its buttons', () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
input: {
|
||||
requestId: 'req-auto-3',
|
||||
message: 'Want "Morning digest" to run automatically?',
|
||||
automationProposal: makeProposal(),
|
||||
},
|
||||
disabled: true,
|
||||
resolvedValue: { kind: 'capabilityDecision', approved: false },
|
||||
toolCallId: 'tc-auto',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved')).toHaveTextContent('Not automated.');
|
||||
expect(queryByTestId('automation-proposal-not-now')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets the user answer an automation card again when the chat takes the answer back', async () => {
|
||||
const input = {
|
||||
requestId: 'req-auto-4',
|
||||
message: 'Want "Morning digest" to run automatically?',
|
||||
automationProposal: makeProposal(),
|
||||
};
|
||||
const { getByTestId, emitted, rerender } = renderComponent({
|
||||
props: { input, toolCallId: 'tc-auto' },
|
||||
global: { plugins: [createTestRouter()], stubs: { RouterLink: false } },
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('automation-proposal-turn-on'));
|
||||
// The chat shows the answer at once, and opens the card again when the request fails.
|
||||
const [[answer]] = emitted<[unknown]>().submit;
|
||||
await rerender({ input, toolCallId: 'tc-auto', resolvedValue: answer });
|
||||
expect(getByTestId('automation-proposal-resolved')).toBeInTheDocument();
|
||||
await rerender({ input, toolCallId: 'tc-auto', resolvedValue: undefined });
|
||||
|
||||
for (const testId of [
|
||||
'automation-proposal-turn-on',
|
||||
'automation-proposal-save',
|
||||
'automation-proposal-not-now',
|
||||
]) {
|
||||
expect(getByTestId(testId)).toBeEnabled();
|
||||
}
|
||||
await fireEvent.click(getByTestId('automation-proposal-save'));
|
||||
expect(emitted().submit).toEqual([
|
||||
[{ kind: 'capabilityDecision', approved: true, values: { target: 'local', activate: true } }],
|
||||
[
|
||||
{
|
||||
kind: 'capabilityDecision',
|
||||
approved: true,
|
||||
values: { target: 'local', activate: false },
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the approval card when no card field is set', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* emits the Assistant confirm body (`InstanceAiConfirmRequest`) as the resume
|
||||
* data; the Agents chat resumes the suspended tool call with it.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import type { InstanceAiConfirmRequest } from '@n8n/api-types';
|
||||
import {
|
||||
N8nApprovalCard,
|
||||
|
|
@ -36,6 +36,9 @@ import { resolvePlanTasksFromConfirmation } from '../../planReview.utils';
|
|||
const props = defineProps<{
|
||||
input: AssistantConfirmationInput;
|
||||
disabled?: boolean;
|
||||
/** The answer of a resolved card, and its tool call: the automation card shows the outcome. */
|
||||
resolvedValue?: unknown;
|
||||
toolCallId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -53,6 +56,14 @@ const textValue = ref('');
|
|||
|
||||
const isInactive = computed(() => props.disabled || submitted.value);
|
||||
|
||||
// An answered card that stays in the chat opens again when its answer fails.
|
||||
watch(
|
||||
() => props.resolvedValue,
|
||||
(now, before) => {
|
||||
if (before !== undefined && now === undefined) submitted.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
/** Same dispatch order as the legacy `InstanceAiConfirmationPanel`. */
|
||||
const variant = computed(() => {
|
||||
const { input } = props;
|
||||
|
|
@ -423,6 +434,8 @@ function onApprovalSelect(key: string) {
|
|||
v-else-if="variant === 'automation-proposal' && input.automationProposal"
|
||||
:proposal="input.automationProposal"
|
||||
:disabled="isInactive"
|
||||
:resolved-value="resolvedValue"
|
||||
:tool-call-id="toolCallId"
|
||||
@submit="submit"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,32 +3,36 @@
|
|||
* The card of `propose_automation`: what starts the workflow, its steps, where it runs and
|
||||
* who can see it. Each button sends a `capabilityDecision` with values that the card offered.
|
||||
*/
|
||||
import { computed, ref, useId } from 'vue';
|
||||
import lowerFirst from 'lodash/lowerFirst';
|
||||
import { computed, nextTick, ref, useId, useTemplateRef, watch } from 'vue';
|
||||
import type { AutomationProposalCard, InstanceAiConfirmRequest } from '@n8n/api-types';
|
||||
import { N8nButton, N8nCard, N8nText, type ButtonVariant } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { describeSchedule } from '@/features/agents/utils/scheduleBuilder';
|
||||
import { capabilityDecisionOf } from '@/features/ai/shared/agentsChat/resolvedCards';
|
||||
import ConfirmationFooter from '../ConfirmationFooter.vue';
|
||||
import AutomationProposalPlace from './AutomationProposalPlace.vue';
|
||||
import AutomationProposalResolved from './AutomationProposalResolved.vue';
|
||||
import AutomationProposalSteps from './AutomationProposalSteps.vue';
|
||||
import {
|
||||
actionOf,
|
||||
activationNoteKey,
|
||||
cardActions,
|
||||
decisionFor,
|
||||
hiddenStepCount,
|
||||
liveStatusKey,
|
||||
timezoneLabel,
|
||||
titleKey,
|
||||
triggerLineKey,
|
||||
visibleSteps,
|
||||
type AutomationAction,
|
||||
type AutomationCardAction,
|
||||
} from './automationProposal';
|
||||
import { triggerText as describeTrigger } from './automationText';
|
||||
|
||||
const props = defineProps<{
|
||||
proposal: AutomationProposalCard;
|
||||
disabled?: boolean;
|
||||
/** The answer of a resolved card. With it, the card shows what happened. */
|
||||
resolvedValue?: unknown;
|
||||
/** The tool call behind the card. The answered card reads its result. */
|
||||
toolCallId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -59,17 +63,30 @@ const hiddenSteps = computed(() => hiddenStepCount(props.proposal));
|
|||
const statusKey = computed(() => liveStatusKey(props.proposal));
|
||||
const noteKey = computed(() => activationNoteKey(props.proposal));
|
||||
|
||||
const triggerText = computed(() => {
|
||||
const line = triggerLineKey(props.proposal.trigger);
|
||||
if (!line) return undefined;
|
||||
if (!('cron' in line)) return i18n.baseText(line.key);
|
||||
const description = describeSchedule(line.cron);
|
||||
if (!description) return i18n.baseText(line.fallbackKey);
|
||||
const timezone = line.timezone === undefined ? '' : timezoneLabel(line.timezone, i18n.locale);
|
||||
// cronstrue starts with a capital ("At 08:00"), and the copy puts it mid-sentence.
|
||||
return i18n.baseText(line.key, {
|
||||
interpolate: { description: lowerFirst(description), timezone },
|
||||
});
|
||||
const triggerText = computed(() => describeTrigger(props.proposal.trigger));
|
||||
|
||||
/** The button that the answer stands for. Only a capability answer resolves the card. */
|
||||
const answeredAction = computed(() => {
|
||||
const decision = capabilityDecisionOf(props.resolvedValue);
|
||||
return decision === undefined ? undefined : actionOf(decision);
|
||||
});
|
||||
|
||||
const openCard = useTemplateRef<InstanceType<typeof N8nCard>>('openCard');
|
||||
|
||||
/** True when no element of the page has focus, for example after the focused one went away. */
|
||||
function isFocusLost(): boolean {
|
||||
const active = document.activeElement;
|
||||
return active === null || active === document.body || !active.isConnected;
|
||||
}
|
||||
|
||||
// A failed answer opens the same card again, so its buttons must work again. The outcome had
|
||||
// focus and goes away, so focus moves to the card. Focus that the user moved elsewhere stays.
|
||||
watch(answeredAction, async (now, before) => {
|
||||
if (before === undefined || now !== undefined) return;
|
||||
submitted.value = false;
|
||||
await nextTick();
|
||||
const element: unknown = openCard.value?.$el;
|
||||
if (element instanceof HTMLElement && isFocusLost()) element.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
function choose(action: AutomationAction) {
|
||||
|
|
@ -80,10 +97,21 @@ function choose(action: AutomationAction) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The answer of this card moves focus to the outcome, because its buttons go away. -->
|
||||
<AutomationProposalResolved
|
||||
v-if="answeredAction"
|
||||
:proposal="proposal"
|
||||
:action="answeredAction"
|
||||
:tool-call-id="toolCallId"
|
||||
:takes-focus="submitted"
|
||||
/>
|
||||
<N8nCard
|
||||
v-else
|
||||
ref="openCard"
|
||||
:class="$style.card"
|
||||
role="group"
|
||||
:aria-labelledby="titleId"
|
||||
tabindex="-1"
|
||||
data-test-id="automation-proposal-card"
|
||||
>
|
||||
<div :class="$style.body">
|
||||
|
|
@ -153,12 +181,16 @@ function choose(action: AutomationAction) {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/focus' as focus;
|
||||
|
||||
// The same container as the other Assistant cards in InstanceAiConfirmationCard.vue.
|
||||
.card {
|
||||
--card--padding: 0;
|
||||
border: 0;
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
|
||||
@include focus.focus-visible-ring;
|
||||
}
|
||||
|
||||
.body {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { N8nBadge, N8nText } from '@n8n/design-system';
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
import { splitName } from '@/features/collaboration/projects/projects.utils';
|
||||
import { placeOf, sharedProjectCount } from './automationProposal';
|
||||
import { placeName as nameOfPlace } from './automationText';
|
||||
|
||||
const props = defineProps<{
|
||||
proposal: AutomationProposalCard;
|
||||
|
|
@ -29,10 +30,7 @@ const visibleToKey = computed(() =>
|
|||
: 'instanceAi.automation.visibleTo',
|
||||
);
|
||||
|
||||
const placeName = computed(() => {
|
||||
if (!place.value.linked) return i18n.baseText('instanceAi.automation.place.thisComputer');
|
||||
return place.value.linkedLabel ?? i18n.baseText('instanceAi.automation.place.otherInstance');
|
||||
});
|
||||
const placeName = computed(() => nameOfPlace(place.value));
|
||||
|
||||
// A personal project is named "First Last <email>". The card shows only the name.
|
||||
const projectName = computed(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* An answered automation card: one line that says what happened, and a link to the workflow
|
||||
* unless nothing was kept ("Not now", or a refusal of the server). The answer gives the first
|
||||
* state. The result of the tool step, when it arrives, says if the workflow is really on.
|
||||
*/
|
||||
import { computed, onMounted, useId, useTemplateRef } from 'vue';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
import type { AutomationProposalCard } from '@n8n/api-types';
|
||||
import { N8nBadge, N8nCard, N8nIcon, N8nLink, N8nText, type IconName } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { useOptionalThread } from '../../instanceAi.store';
|
||||
import { placeOf, type AutomationAction } from './automationProposal';
|
||||
import { resolvedStatus, toolOutcome, type AutomationResolvedTone } from './automationResolved';
|
||||
import { placeName, triggerText } from './automationText';
|
||||
|
||||
const props = defineProps<{
|
||||
proposal: AutomationProposalCard;
|
||||
action: AutomationAction;
|
||||
/** The tool call behind the card. Without it, the line shows what the answer asked for. */
|
||||
toolCallId?: string;
|
||||
/** True when the user answered in this card, so focus moves here from the removed buttons. */
|
||||
takesFocus?: boolean;
|
||||
}>();
|
||||
|
||||
// Every state has an icon, so the text does not move when the result arrives. "Not now" ends
|
||||
// the card without a result to report, so it shows a neutral sign.
|
||||
const TONE_ICONS: Record<AutomationResolvedTone, IconName> = {
|
||||
success: 'circle-check',
|
||||
warning: 'triangle-alert',
|
||||
pending: 'loader-circle',
|
||||
neutral: 'circle-minus',
|
||||
};
|
||||
|
||||
const i18n = useI18n();
|
||||
const thread = useOptionalThread();
|
||||
const statusId = useId();
|
||||
const root = useTemplateRef<InstanceType<typeof N8nCard>>('root');
|
||||
|
||||
const outcome = computed(() => {
|
||||
if (!props.toolCallId) return undefined;
|
||||
const call = thread?.findToolCall(props.toolCallId);
|
||||
return call === undefined ? undefined : toolOutcome(call);
|
||||
});
|
||||
|
||||
const status = computed(() => resolvedStatus(props.action, props.proposal, outcome.value));
|
||||
const trigger = computed(() => triggerText(props.proposal.trigger, 'clause'));
|
||||
const place = computed(() => placeName(placeOf(props.proposal)));
|
||||
const workflowRoute = computed(() => ({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { workflowId: props.proposal.workflowId },
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.takesFocus) return;
|
||||
const element: unknown = root.value?.$el;
|
||||
if (element instanceof HTMLElement) element.focus({ preventScroll: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCard
|
||||
ref="root"
|
||||
:class="[$style.card, $style[status.tone]]"
|
||||
role="group"
|
||||
:aria-labelledby="statusId"
|
||||
tabindex="-1"
|
||||
data-test-id="automation-proposal-resolved"
|
||||
>
|
||||
<div :class="$style.row">
|
||||
<span :class="$style.iconSlot" aria-hidden="true">
|
||||
<N8nIcon
|
||||
:icon="TONE_ICONS[status.tone]"
|
||||
:spin="status.tone === 'pending'"
|
||||
size="medium"
|
||||
:class="$style.icon"
|
||||
/>
|
||||
</span>
|
||||
<N8nText
|
||||
:id="statusId"
|
||||
tag="p"
|
||||
size="small"
|
||||
:class="$style.status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
:data-status="status.kind"
|
||||
data-test-id="automation-proposal-resolved-status"
|
||||
>
|
||||
<I18nT :keypath="status.messageKey" scope="global">
|
||||
<template #title>{{ proposal.title }}</template>
|
||||
<template #trigger>{{ trigger }}</template>
|
||||
<template #place>
|
||||
<N8nBadge variant="outline" :class="$style.chip" :title="place">{{ place }}</N8nBadge>
|
||||
</template>
|
||||
</I18nT>
|
||||
</N8nText>
|
||||
<N8nLink
|
||||
v-if="status.showsLink"
|
||||
:to="workflowRoute"
|
||||
size="small"
|
||||
:class="$style.link"
|
||||
:aria-label="
|
||||
i18n.baseText('instanceAi.automation.resolved.openWorkflowLabel', {
|
||||
interpolate: { title: proposal.title },
|
||||
})
|
||||
"
|
||||
data-test-id="automation-proposal-open-workflow"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.automation.resolved.openWorkflow') }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
</N8nCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/focus' as focus;
|
||||
|
||||
// The same container as the open card in AutomationProposalCard.vue.
|
||||
.card {
|
||||
--card--padding: var(--spacing--xs) var(--spacing--sm);
|
||||
border: 0;
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
|
||||
@include focus.focus-visible-ring;
|
||||
}
|
||||
|
||||
// The link stays at the end of the line; a long sentence wraps beside it. The icon and the
|
||||
// link line up with the first line of the sentence, so the row uses the size of that text.
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--xs);
|
||||
font-size: var(--font-size--2xs);
|
||||
line-height: var(--line-height--lg);
|
||||
}
|
||||
|
||||
// One line high, so the icon is centred on the first line.
|
||||
.iconSlot {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: calc(var(--font-size--2xs) * var(--line-height--lg));
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.success {
|
||||
.icon {
|
||||
color: var(--icon-color--success);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-color--success);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
.icon {
|
||||
color: var(--icon-color--warning);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-color--warning);
|
||||
}
|
||||
}
|
||||
|
||||
// A badge keeps its text on one line. It must shrink in the line to show its ellipsis.
|
||||
.chip {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.link {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius--sm);
|
||||
|
||||
@include focus.focus-visible-ring-offset;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { defineComponent, h, nextTick, ref, type PropType, type Ref } from 'vue';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, within } from '@testing-library/vue';
|
||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import type { AutomationProposalCard as Proposal } from '@n8n/api-types';
|
||||
import type {
|
||||
AutomationProposalCard as Proposal,
|
||||
InstanceAiConfirmRequest,
|
||||
InstanceAiToolCallState,
|
||||
} from '@n8n/api-types';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { provideThread, type ThreadRuntime } from '../../../instanceAi.store';
|
||||
import { createTestRouter } from '../../../navigation/__tests__/navigationFixtures';
|
||||
import AutomationProposalCard from '../AutomationProposalCard.vue';
|
||||
import { makeManualProposal, makeProposal } from './automationProposalFixtures';
|
||||
|
||||
|
|
@ -332,3 +339,339 @@ describe('AutomationProposalCard', () => {
|
|||
expect(getByTestId('automation-proposal-visible-to')).toHaveTextContent(projectName);
|
||||
});
|
||||
});
|
||||
|
||||
const TURN_ON = {
|
||||
kind: 'capabilityDecision',
|
||||
approved: true,
|
||||
values: { target: 'local', activate: true },
|
||||
};
|
||||
const SAVE = {
|
||||
kind: 'capabilityDecision',
|
||||
approved: true,
|
||||
values: { target: 'local', activate: false },
|
||||
};
|
||||
const DECLINE = { kind: 'capabilityDecision', approved: false };
|
||||
const KEPT = { workflowId: 'wf-1', url: 'http://localhost:5678/workflow/wf-1', kept: true };
|
||||
|
||||
/** The tool call that the thread mirror holds for the card, as the Assistant chat fills it. */
|
||||
function toolCall(fields: Partial<InstanceAiToolCallState>): InstanceAiToolCallState {
|
||||
return {
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'propose_automation',
|
||||
args: {},
|
||||
isLoading: false,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
/** A chat that holds the card, so the card can read the result of its tool call. */
|
||||
const InChat = defineComponent({
|
||||
props: {
|
||||
proposal: { type: Object as PropType<Proposal>, required: true },
|
||||
answer: { type: null, default: undefined },
|
||||
call: {
|
||||
type: Object as PropType<{ value: InstanceAiToolCallState | undefined }>,
|
||||
default: undefined,
|
||||
},
|
||||
/** The answer that the chat holds, so a test can take it back as a failed request does. */
|
||||
state: { type: Object as PropType<Ref<unknown>>, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
const call = props.call;
|
||||
if (call) {
|
||||
provideThread({
|
||||
id: 'thread-1',
|
||||
findToolCall: (id: string) => (id === 'tc-1' ? call.value : undefined),
|
||||
} as unknown as ThreadRuntime);
|
||||
}
|
||||
// The chat resolves the card with the answer that the card sends.
|
||||
const resolvedValue = props.state ?? ref<unknown>(props.answer);
|
||||
const onSubmit = (body: InstanceAiConfirmRequest) => {
|
||||
resolvedValue.value = body;
|
||||
};
|
||||
return () =>
|
||||
h(AutomationProposalCard, {
|
||||
proposal: props.proposal,
|
||||
resolvedValue: resolvedValue.value,
|
||||
toolCallId: 'tc-1',
|
||||
onSubmit,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const renderInChat = createComponentRenderer(InChat, { pinia: createTestingPinia() });
|
||||
|
||||
function renderAnswered(
|
||||
answer: unknown,
|
||||
{
|
||||
proposal = makeProposal(),
|
||||
call,
|
||||
state,
|
||||
}: {
|
||||
proposal?: Proposal;
|
||||
call?: { value: InstanceAiToolCallState | undefined };
|
||||
state?: Ref<unknown>;
|
||||
} = {},
|
||||
) {
|
||||
return renderInChat({
|
||||
props: { proposal, answer, call, state },
|
||||
global: { plugins: [createTestRouter()], stubs: { RouterLink: false } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('AutomationProposalCard after the answer', () => {
|
||||
it('says that it is on, when and where it runs, and links to the workflow', () => {
|
||||
const { getByTestId, getByRole, queryByTestId, queryAllByRole } = renderAnswered(TURN_ON);
|
||||
|
||||
expect(queryByTestId('automation-proposal-card')).not.toBeInTheDocument();
|
||||
expect(queryAllByRole('button')).toHaveLength(0);
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
`It's on. "Morning digest" runs at 08:00, Monday through Friday (United Kingdom Time) on This computer.`,
|
||||
);
|
||||
const link = getByRole('link', { name: 'Open workflow "Morning digest"' });
|
||||
expect(link).toBe(getByTestId('automation-proposal-open-workflow'));
|
||||
expect(link).toHaveAttribute('href', '/workflow/wf-1');
|
||||
expect(link).toHaveTextContent('Open workflow');
|
||||
});
|
||||
|
||||
it('names the trigger of a workflow without a schedule, and a linked place by its label', () => {
|
||||
const proposal = makeProposal({
|
||||
trigger: { kind: 'webhook' },
|
||||
recommended: { targetId: 'cloud-1', kind: 'linked', reasons: ['always-on-trigger'] },
|
||||
targets: [{ id: 'cloud-1', kind: 'linked', label: 'Cloud', status: 'online' }],
|
||||
offered: { target: ['cloud-1'], activate: [true, false] },
|
||||
});
|
||||
const { getByTestId } = renderAnswered(TURN_ON, { proposal });
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
`It's on. "Morning digest" runs when a request arrives on Cloud.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('says that a saved workflow is off until the user turns it on', () => {
|
||||
const { getByTestId } = renderAnswered(SAVE);
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
'Saved. "Morning digest" is off until you turn it on.',
|
||||
);
|
||||
expect(getByTestId('automation-proposal-open-workflow')).toHaveAttribute(
|
||||
'href',
|
||||
'/workflow/wf-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('says that "Save" keeps the live version of a live workflow running', () => {
|
||||
const proposal = makeProposal({ active: true, hasUnpublishedChanges: true });
|
||||
const { getByTestId } = renderAnswered(SAVE, { proposal });
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
'Saved. The live version of "Morning digest" keeps running.',
|
||||
);
|
||||
});
|
||||
|
||||
it('says that a saved manual workflow is in the workflows, without asking to turn it on', async () => {
|
||||
const { getByTestId, getByRole } = renderAnswered(undefined, {
|
||||
proposal: makeManualProposal(),
|
||||
});
|
||||
|
||||
await fireEvent.click(getByRole('button', { name: 'Save workflow' }));
|
||||
|
||||
const status = getByTestId('automation-proposal-resolved-status');
|
||||
expect(status).toHaveTextContent('Saved. "Morning digest" is in your workflows.');
|
||||
expect(status).not.toHaveTextContent('turn it on');
|
||||
expect(getByTestId('automation-proposal-open-workflow')).toHaveAttribute(
|
||||
'href',
|
||||
'/workflow/wf-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('says that a saved workflow stays off until someone turns it on, as the card said', async () => {
|
||||
const proposal = makeProposal({
|
||||
canActivate: false,
|
||||
offered: { target: ['local'], activate: [false] },
|
||||
});
|
||||
const { getByTestId, getByRole } = renderAnswered(undefined, { proposal });
|
||||
expect(getByTestId('automation-proposal-note')).toHaveTextContent(
|
||||
'It stays off until someone turns it on.',
|
||||
);
|
||||
|
||||
await fireEvent.click(getByRole('button', { name: 'Save workflow' }));
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
'Saved. "Morning digest" stays off until someone turns it on.',
|
||||
);
|
||||
expect(getByTestId('automation-proposal-open-workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says that a schedule it cannot describe runs at set times', () => {
|
||||
const proposal = makeProposal({ trigger: { kind: 'schedule', cron: 'not a cron' } });
|
||||
const { getByTestId } = renderAnswered(TURN_ON, { proposal });
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
`It's on. "Morning digest" runs at set times on This computer.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('says "Not automated." for "Not now", without a link', () => {
|
||||
const { getByTestId, queryByRole, queryByTestId } = renderAnswered(DECLINE);
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent('Not automated.');
|
||||
expect(queryByTestId('automation-proposal-open-workflow')).not.toBeInTheDocument();
|
||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says "Turning it on…" until the tool result arrives, then that it is on', async () => {
|
||||
// Right after the answer, the chat keeps the answer in the place of the result.
|
||||
const call = ref(toolCall({ result: TURN_ON }));
|
||||
const { getByTestId } = renderAnswered(TURN_ON, { call });
|
||||
const status = getByTestId('automation-proposal-resolved-status');
|
||||
const iconOf = () => getByTestId('automation-proposal-resolved').querySelector('[data-icon]');
|
||||
|
||||
expect(status).toHaveTextContent('Turning it on…');
|
||||
expect(getByTestId('automation-proposal-open-workflow')).toBeInTheDocument();
|
||||
// The waiting state holds the place of the icon, so the text does not move later.
|
||||
expect(iconOf()).toHaveAttribute('data-icon', 'loader-circle');
|
||||
expect(iconOf()?.closest('[aria-hidden="true"]')).not.toBeNull();
|
||||
expect(status.querySelector('[role="status"]')).toBeNull();
|
||||
|
||||
call.value = toolCall({ result: { ...KEPT, active: true } });
|
||||
await nextTick();
|
||||
|
||||
expect(status).toHaveTextContent(`It's on. "Morning digest" runs at 08:00`);
|
||||
expect(iconOf()).toHaveAttribute('data-icon', 'circle-check');
|
||||
});
|
||||
|
||||
it('says that the workflow was saved but is off when the result is not active', async () => {
|
||||
const call = ref(toolCall({ result: TURN_ON }));
|
||||
const { getByTestId } = renderAnswered(TURN_ON, { call });
|
||||
|
||||
call.value = toolCall({
|
||||
result: { ...KEPT, active: false, error: 'Saved "Morning digest", but could not turn it on' },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
"Saved, but it couldn't be turned on. Open the workflow to check it.",
|
||||
);
|
||||
expect(getByTestId('automation-proposal-open-workflow')).toHaveAttribute(
|
||||
'href',
|
||||
'/workflow/wf-1',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([TURN_ON, SAVE])(
|
||||
'says "Not automated" like the tool step when an admin blocked it, without a link',
|
||||
(answer) => {
|
||||
const blocked = toolCall({ result: { denied: true, message: 'Blocked' } });
|
||||
const { getByTestId, queryByTestId } = renderAnswered(answer, { call: ref(blocked) });
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
'Not automated. "Morning digest" couldn\'t be saved.',
|
||||
);
|
||||
expect(queryByTestId('automation-proposal-open-workflow')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('asks the user to check the workflow when the call failed, because it may be kept', () => {
|
||||
const failed = toolCall({ error: 'Could not reach the database' });
|
||||
const { getByTestId } = renderAnswered(TURN_ON, { call: ref(failed) });
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent(
|
||||
'Something went wrong with "Morning digest". Open the workflow to check it.',
|
||||
);
|
||||
expect(getByTestId('automation-proposal-open-workflow')).toHaveAttribute(
|
||||
'href',
|
||||
'/workflow/wf-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('says that the changes are live after "Make changes live" on a live workflow', async () => {
|
||||
const proposal = makeProposal({ active: true, hasUnpublishedChanges: true });
|
||||
const call = ref(toolCall({ result: TURN_ON }));
|
||||
const { getByTestId } = renderAnswered(TURN_ON, { proposal, call });
|
||||
const status = getByTestId('automation-proposal-resolved-status');
|
||||
|
||||
expect(status).toHaveTextContent('Making your changes live…');
|
||||
|
||||
call.value = toolCall({ result: { ...KEPT, active: true } });
|
||||
await nextTick();
|
||||
|
||||
expect(status).toHaveTextContent(
|
||||
'Your changes are live. "Morning digest" runs at 08:00, Monday through Friday (United Kingdom Time) on This computer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('announces the outcome politely, as one message', () => {
|
||||
const { getByTestId } = renderAnswered(TURN_ON, { call: ref(toolCall({ result: TURN_ON })) });
|
||||
const status = getByTestId('automation-proposal-resolved-status');
|
||||
|
||||
expect(status).toHaveAttribute('aria-live', 'polite');
|
||||
expect(status).toHaveAttribute('aria-atomic', 'true');
|
||||
expect(getByTestId('automation-proposal-resolved')).toHaveAccessibleName('Turning it on…');
|
||||
});
|
||||
|
||||
it('keeps the open card for a value that is not an answer of the card', () => {
|
||||
const { getByTestId, queryByTestId } = renderAnswered({ ...KEPT, active: true });
|
||||
|
||||
expect(getByTestId('automation-proposal-card')).toBeInTheDocument();
|
||||
expect(queryByTestId('automation-proposal-resolved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves focus to the outcome when the user answers, because the buttons go away', async () => {
|
||||
const { getByTestId } = renderAnswered(undefined);
|
||||
|
||||
await fireEvent.click(getByTestId('automation-proposal-turn-on'));
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('leaves focus where it is for a card that was answered before it rendered', () => {
|
||||
const { getByTestId } = renderAnswered(SAVE);
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved')).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('leaves focus where the user moved it when the chat takes a failed answer back', async () => {
|
||||
const state = ref<unknown>(undefined);
|
||||
const { getByTestId } = renderAnswered(undefined, { state });
|
||||
const elsewhere = document.body.appendChild(document.createElement('button'));
|
||||
|
||||
await fireEvent.click(getByTestId('automation-proposal-turn-on'));
|
||||
elsewhere.focus();
|
||||
state.value = undefined;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('automation-proposal-card')).toBeInTheDocument();
|
||||
expect(elsewhere).toHaveFocus();
|
||||
elsewhere.remove();
|
||||
});
|
||||
|
||||
it('lets the user answer again when the chat takes a failed answer back', async () => {
|
||||
const state = ref<unknown>(undefined);
|
||||
const { getByTestId, queryByTestId } = renderAnswered(undefined, { state });
|
||||
|
||||
await fireEvent.click(getByTestId('automation-proposal-turn-on'));
|
||||
expect(getByTestId('automation-proposal-resolved')).toBeInTheDocument();
|
||||
|
||||
state.value = undefined;
|
||||
await nextTick();
|
||||
|
||||
expect(queryByTestId('automation-proposal-resolved')).not.toBeInTheDocument();
|
||||
// The outcome had focus. Focus moves to the card, not to the page.
|
||||
await waitFor(() => expect(getByTestId('automation-proposal-card')).toHaveFocus());
|
||||
for (const testId of [
|
||||
'automation-proposal-turn-on',
|
||||
'automation-proposal-save',
|
||||
'automation-proposal-not-now',
|
||||
]) {
|
||||
expect(getByTestId(testId)).toBeEnabled();
|
||||
}
|
||||
|
||||
await fireEvent.click(getByTestId('automation-proposal-not-now'));
|
||||
|
||||
expect(state.value).toEqual(DECLINE);
|
||||
expect(getByTestId('automation-proposal-resolved-status')).toHaveTextContent('Not automated.');
|
||||
expect(getByTestId('automation-proposal-resolved')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
automationTriggerKindSchema,
|
||||
type AutomationProposalCard as Proposal,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import { capabilityDecisionOf } from '@/features/ai/shared/agentsChat/resolvedCards';
|
||||
import {
|
||||
actionOf,
|
||||
decisionFor,
|
||||
triggerLineKey,
|
||||
type AutomationAction,
|
||||
} from '../automationProposal';
|
||||
import { resolvedStatus, toolOutcome, type AutomationToolOutcome } from '../automationResolved';
|
||||
import { makeManualProposal, makeProposal } from './automationProposalFixtures';
|
||||
|
||||
const RESULT = { workflowId: 'wf-1', url: 'http://localhost:5678/workflow/wf-1', kept: true };
|
||||
const ON = { ...RESULT, active: true };
|
||||
const OFF = { ...RESULT, active: false };
|
||||
const OFF_WITH_ERROR = { ...OFF, error: 'Saved "Morning digest", but could not turn it on: x' };
|
||||
const DENIED = { denied: true, message: 'The user declined this action.' };
|
||||
|
||||
const TURN_ON = { kind: 'capabilityDecision', approved: true, values: { activate: true } };
|
||||
|
||||
const WAITING: AutomationToolOutcome = { kind: 'waiting' };
|
||||
const REFUSED: AutomationToolOutcome = { kind: 'refused' };
|
||||
const FAILED: AutomationToolOutcome = { kind: 'failed' };
|
||||
const kept = (active: boolean, failed = false): AutomationToolOutcome => ({
|
||||
kind: 'kept',
|
||||
active,
|
||||
failed,
|
||||
});
|
||||
|
||||
const ACTIONS: AutomationAction[] = ['activate', 'save', 'decline'];
|
||||
|
||||
describe('actionOf', () => {
|
||||
it.each([
|
||||
['a decline', { approved: false }, 'decline'],
|
||||
[
|
||||
'a decline that also says activate',
|
||||
{ approved: false, values: { activate: true } },
|
||||
'decline',
|
||||
],
|
||||
['an approval with activate', { approved: true, values: { activate: true } }, 'activate'],
|
||||
['an approval without activate', { approved: true, values: { activate: false } }, 'save'],
|
||||
['an approval without values', { approved: true }, 'save'],
|
||||
['an approval with activate as text', { approved: true, values: { activate: 'true' } }, 'save'],
|
||||
] as const)('reads %s as "%s"', (_name, fields, expected) => {
|
||||
expect(actionOf({ kind: 'capabilityDecision', ...fields })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerLineKey as a clause', () => {
|
||||
it.each([
|
||||
['schedule', 'instanceAi.automation.resolved.trigger.schedule'],
|
||||
['webhook', 'instanceAi.automation.resolved.trigger.webhook'],
|
||||
['form', 'instanceAi.automation.resolved.trigger.form'],
|
||||
['chat', 'instanceAi.automation.resolved.trigger.chat'],
|
||||
['app-event', 'instanceAi.automation.resolved.trigger.appEvent'],
|
||||
['other', 'instanceAi.automation.resolved.trigger.other'],
|
||||
] as const)('names a %s trigger with a clause', (kind, key) => {
|
||||
expect(triggerLineKey({ kind }, 'clause')).toEqual({ key });
|
||||
});
|
||||
|
||||
it('keeps the schedule and its zone, with the clause of the kind as the fallback', () => {
|
||||
const trigger = { kind: 'schedule' as const, cron: '0 8 * * 1-5', timezone: 'Europe/London' };
|
||||
|
||||
expect(triggerLineKey(trigger, 'clause')).toEqual({
|
||||
key: 'instanceAi.automation.resolved.trigger.cronWithTimezone',
|
||||
cron: '0 8 * * 1-5',
|
||||
timezone: 'Europe/London',
|
||||
fallbackKey: 'instanceAi.automation.resolved.trigger.schedule',
|
||||
});
|
||||
expect(triggerLineKey({ kind: 'schedule', cron: '0 8 * * *' }, 'clause')).toEqual({
|
||||
key: 'instanceAi.automation.resolved.trigger.cron',
|
||||
cron: '0 8 * * *',
|
||||
fallbackKey: 'instanceAi.automation.resolved.trigger.schedule',
|
||||
});
|
||||
});
|
||||
|
||||
it('has no clause for a manual workflow, and keeps the full line as the default', () => {
|
||||
expect(triggerLineKey({ kind: 'manual' }, 'clause')).toBeUndefined();
|
||||
expect(triggerLineKey({ kind: 'webhook' })).toEqual({
|
||||
key: 'instanceAi.automation.trigger.webhook',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolOutcome', () => {
|
||||
it('waits while the tool step holds the answer in place of the result', () => {
|
||||
expect(toolOutcome({ result: TURN_ON })).toEqual(WAITING);
|
||||
expect(toolOutcome({})).toEqual(WAITING);
|
||||
});
|
||||
|
||||
it('reads a kept workflow from the result', () => {
|
||||
expect(toolOutcome({ result: ON })).toEqual(kept(true));
|
||||
expect(toolOutcome({ result: OFF_WITH_ERROR })).toEqual(kept(false, true));
|
||||
});
|
||||
|
||||
it('reads a declined or blocked result as refused', () => {
|
||||
expect(toolOutcome({ result: DENIED })).toEqual(REFUSED);
|
||||
});
|
||||
|
||||
it('reads a call that ended in an error as failed, whatever its result says', () => {
|
||||
expect(toolOutcome({ error: 'You cannot reach this workflow' })).toEqual(FAILED);
|
||||
// The error wins: a failed call has no result to trust.
|
||||
expect(toolOutcome({ result: ON, error: 'Tool call failed' })).toEqual(FAILED);
|
||||
expect(toolOutcome({ result: DENIED, error: '' })).toEqual(FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvedStatus', () => {
|
||||
const offProposal = makeProposal();
|
||||
const liveProposal = makeProposal({ active: true, hasUnpublishedChanges: true });
|
||||
const manualProposal = makeManualProposal();
|
||||
// The user may not publish, or an admin blocked publishing for the Assistant.
|
||||
const lockedProposal = makeProposal({
|
||||
canActivate: false,
|
||||
offered: { target: ['local'], activate: [false] },
|
||||
});
|
||||
|
||||
it.each([
|
||||
['decline', offProposal, undefined, 'declined'],
|
||||
['decline', offProposal, kept(true), 'declined'],
|
||||
['decline', offProposal, FAILED, 'declined'],
|
||||
['activate', offProposal, undefined, 'on'],
|
||||
['activate', offProposal, WAITING, 'turning-on'],
|
||||
['activate', offProposal, kept(true), 'on'],
|
||||
['activate', offProposal, kept(false), 'not-on'],
|
||||
['activate', offProposal, kept(false, true), 'not-on'],
|
||||
['activate', liveProposal, kept(true, true), 'not-live'],
|
||||
['activate', liveProposal, kept(false, true), 'not-on'],
|
||||
['activate', offProposal, REFUSED, 'not-saved'],
|
||||
['activate', offProposal, FAILED, 'failed'],
|
||||
// "Make changes live" on a workflow that was on already.
|
||||
['activate', liveProposal, undefined, 'changes-live'],
|
||||
['activate', liveProposal, WAITING, 'making-live'],
|
||||
['activate', liveProposal, kept(true), 'changes-live'],
|
||||
['activate', liveProposal, REFUSED, 'not-saved'],
|
||||
['activate', liveProposal, FAILED, 'failed'],
|
||||
['save', offProposal, undefined, 'saved'],
|
||||
['save', offProposal, WAITING, 'saved'],
|
||||
['save', offProposal, kept(false), 'saved'],
|
||||
['save', liveProposal, undefined, 'saved-live'],
|
||||
['save', liveProposal, WAITING, 'saved-live'],
|
||||
['save', liveProposal, kept(true), 'saved-live'],
|
||||
// The result is the truth when it disagrees with the card.
|
||||
['save', liveProposal, kept(false), 'saved'],
|
||||
['save', offProposal, kept(true), 'saved-live'],
|
||||
['save', offProposal, REFUSED, 'not-saved'],
|
||||
['save', liveProposal, FAILED, 'failed'],
|
||||
// A manual workflow cannot be turned on, so "Save" does not ask the user to.
|
||||
['save', manualProposal, undefined, 'saved-manual'],
|
||||
['save', manualProposal, WAITING, 'saved-manual'],
|
||||
['save', manualProposal, kept(false), 'saved-manual'],
|
||||
['save', manualProposal, REFUSED, 'not-saved'],
|
||||
// The card said that it cannot turn the workflow on, so "Save" does not ask either.
|
||||
['save', lockedProposal, undefined, 'saved-locked'],
|
||||
['save', lockedProposal, WAITING, 'saved-locked'],
|
||||
['save', lockedProposal, kept(false), 'saved-locked'],
|
||||
['save', lockedProposal, FAILED, 'failed'],
|
||||
// A live version keeps running, whatever the trigger or the rights.
|
||||
['save', manualProposal, kept(true), 'saved-live'],
|
||||
['save', makeProposal({ canActivate: false, active: true }), undefined, 'saved-live'],
|
||||
['save', makeProposal({ canActivate: false, active: true }), kept(false), 'saved-locked'],
|
||||
] as const)('"%s" with outcome %#: %s', (action, proposal, outcome, kind) => {
|
||||
expect(resolvedStatus(action, proposal, outcome).kind).toBe(kind);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: 'on', action: 'activate', outcome: kept(true), tone: 'success', showsLink: true },
|
||||
{ kind: 'turning-on', action: 'activate', outcome: WAITING, tone: 'pending', showsLink: true },
|
||||
{ kind: 'not-on', action: 'activate', outcome: kept(false), tone: 'warning', showsLink: true },
|
||||
{ kind: 'saved', action: 'save', outcome: undefined, tone: 'success', showsLink: true },
|
||||
{ kind: 'saved-manual', action: 'save', outcome: undefined, tone: 'success', showsLink: true },
|
||||
{ kind: 'saved-locked', action: 'save', outcome: undefined, tone: 'success', showsLink: true },
|
||||
{ kind: 'not-saved', action: 'save', outcome: REFUSED, tone: 'warning', showsLink: false },
|
||||
{ kind: 'failed', action: 'save', outcome: FAILED, tone: 'warning', showsLink: true },
|
||||
{ kind: 'declined', action: 'decline', outcome: undefined, tone: 'neutral', showsLink: false },
|
||||
] as const)('shows "$kind" with its tone and link', ({ kind, action, outcome, ...view }) => {
|
||||
const proposals: Partial<Record<string, Proposal>> = {
|
||||
'saved-manual': makeManualProposal(),
|
||||
'saved-locked': makeProposal({ canActivate: false }),
|
||||
};
|
||||
const proposal = proposals[kind] ?? makeProposal();
|
||||
|
||||
expect(resolvedStatus(action, proposal, outcome)).toMatchObject({ kind, ...view });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['on', 'instanceAi.automation.resolved.on'],
|
||||
['changes-live', 'instanceAi.automation.resolved.changesLive'],
|
||||
['turning-on', 'instanceAi.automation.resolved.turningOn'],
|
||||
['making-live', 'instanceAi.automation.resolved.makingLive'],
|
||||
['not-on', 'instanceAi.automation.resolved.notOn'],
|
||||
['not-live', 'instanceAi.automation.resolved.notLive'],
|
||||
['saved', 'instanceAi.automation.resolved.saved'],
|
||||
['saved-live', 'instanceAi.automation.resolved.savedLive'],
|
||||
['saved-manual', 'instanceAi.automation.resolved.savedManual'],
|
||||
['saved-locked', 'instanceAi.automation.resolved.savedLocked'],
|
||||
['not-saved', 'instanceAi.automation.resolved.notSaved'],
|
||||
['failed', 'instanceAi.automation.resolved.failed'],
|
||||
['declined', 'instanceAi.automation.resolved.declined'],
|
||||
] as const)('has its own copy for "%s"', (kind, key) => {
|
||||
const live = makeProposal({ active: true, hasUnpublishedChanges: true });
|
||||
const cases = {
|
||||
on: ['activate', undefined, makeProposal()],
|
||||
'changes-live': ['activate', kept(true), live],
|
||||
'turning-on': ['activate', WAITING, makeProposal()],
|
||||
'making-live': ['activate', WAITING, live],
|
||||
'not-on': ['activate', kept(false), makeProposal()],
|
||||
'not-live': ['activate', kept(true, true), makeProposal()],
|
||||
saved: ['save', kept(false), makeProposal()],
|
||||
'saved-live': ['save', kept(true), makeProposal()],
|
||||
'saved-manual': ['save', kept(false), makeManualProposal()],
|
||||
'saved-locked': ['save', kept(false), makeProposal({ canActivate: false })],
|
||||
'not-saved': ['save', REFUSED, makeProposal()],
|
||||
failed: ['activate', FAILED, makeProposal()],
|
||||
declined: ['decline', undefined, makeProposal()],
|
||||
} as const;
|
||||
const [action, outcome, proposal] = cases[kind];
|
||||
|
||||
expect(resolvedStatus(action, proposal, outcome)).toMatchObject({
|
||||
kind,
|
||||
messageKey: key,
|
||||
});
|
||||
});
|
||||
|
||||
it('says that a workflow without a trigger line is active, without a "runs" clause', () => {
|
||||
const status = resolvedStatus('activate', makeManualProposal(), kept(true));
|
||||
|
||||
expect(status).toEqual({
|
||||
kind: 'on',
|
||||
messageKey: 'instanceAi.automation.resolved.onNoTrigger',
|
||||
tone: 'success',
|
||||
showsLink: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('says that the changes are live, without a "runs" clause, for a live workflow without a trigger line', () => {
|
||||
const status = resolvedStatus('activate', makeManualProposal({ active: true }), kept(true));
|
||||
|
||||
expect(status).toEqual({
|
||||
kind: 'changes-live',
|
||||
messageKey: 'instanceAi.automation.resolved.changesLiveNoTrigger',
|
||||
tone: 'success',
|
||||
showsLink: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the "runs" clause for every other state, also without a trigger line', () => {
|
||||
expect(resolvedStatus('save', makeManualProposal(), kept(false)).messageKey).toBe(
|
||||
'instanceAi.automation.resolved.savedManual',
|
||||
);
|
||||
expect(resolvedStatus('activate', makeManualProposal(), WAITING).messageKey).toBe(
|
||||
'instanceAi.automation.resolved.turningOn',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Properties ---
|
||||
|
||||
const triggerArb: fc.Arbitrary<Proposal['trigger']> = fc
|
||||
.constantFrom(...automationTriggerKindSchema.options)
|
||||
.map((kind) => (kind === 'schedule' ? { kind, cron: '0 8 * * 1-5' } : { kind }));
|
||||
|
||||
const proposalArb = fc
|
||||
.record({
|
||||
active: fc.boolean(),
|
||||
hasUnpublishedChanges: fc.boolean(),
|
||||
canActivate: fc.boolean(),
|
||||
trigger: triggerArb,
|
||||
offered: fc.record({
|
||||
target: fc.uniqueArray(fc.constantFrom('local', 'cloud-1'), { minLength: 1 }),
|
||||
activate: fc.uniqueArray(fc.boolean(), { minLength: 1 }),
|
||||
}),
|
||||
})
|
||||
.map((fields) => makeProposal(fields));
|
||||
|
||||
const outcomeArb: fc.Arbitrary<AutomationToolOutcome | undefined> = fc.oneof(
|
||||
fc.constant(undefined),
|
||||
fc.constant(WAITING),
|
||||
fc.constant(REFUSED),
|
||||
fc.constant(FAILED),
|
||||
fc.record({ kind: fc.constant('kept' as const), active: fc.boolean(), failed: fc.boolean() }),
|
||||
);
|
||||
|
||||
const decisionArb = fc
|
||||
.record({
|
||||
approved: fc.boolean(),
|
||||
values: fc.option(
|
||||
fc.dictionary(
|
||||
fc.constantFrom('activate', 'target'),
|
||||
fc.oneof(fc.boolean(), fc.constantFrom('local', 'true')),
|
||||
),
|
||||
{ nil: undefined },
|
||||
),
|
||||
})
|
||||
.map((fields) => ({ kind: 'capabilityDecision' as const, ...fields }));
|
||||
|
||||
describe('answered card properties', () => {
|
||||
it('reads every answer of the card back as its button', () => {
|
||||
fc.assert(
|
||||
fc.property(proposalArb, fc.constantFrom(...ACTIONS), (proposal, action) => {
|
||||
const decision = capabilityDecisionOf(decisionFor(action, proposal));
|
||||
|
||||
expect(decision).toBeDefined();
|
||||
expect(decision && actionOf(decision)).toBe(action);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('says that it is on only for "Turn it on", and only when no result says otherwise', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.constantFrom(...ACTIONS), proposalArb, outcomeArb, (action, p, outcome) => {
|
||||
const { kind } = resolvedStatus(action, p, outcome);
|
||||
const resultSaysOn =
|
||||
outcome === undefined || (outcome.kind === 'kept' && outcome.active && !outcome.failed);
|
||||
const saysOn = kind === 'on' || kind === 'changes-live';
|
||||
|
||||
expect(saysOn).toBe(action === 'activate' && resultSaysOn);
|
||||
// A workflow that was on already gets "Your changes are live", never "It's on".
|
||||
if (saysOn) expect(kind).toBe(p.active ? 'changes-live' : 'on');
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('declines only for "Not now", and links the workflow unless nothing was kept', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.constantFrom(...ACTIONS), proposalArb, outcomeArb, (action, p, outcome) => {
|
||||
const status = resolvedStatus(action, p, outcome);
|
||||
const answered = action !== 'decline';
|
||||
const refused = answered && outcome?.kind === 'refused';
|
||||
|
||||
expect(status.kind === 'declined').toBe(!answered);
|
||||
expect(status.kind === 'not-saved').toBe(refused);
|
||||
expect(status.kind === 'failed').toBe(answered && outcome?.kind === 'failed');
|
||||
expect(status.showsLink).toBe(answered && !refused);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('asks the user to turn a saved workflow on only when the user can', () => {
|
||||
fc.assert(
|
||||
fc.property(proposalArb, outcomeArb, (p, outcome) => {
|
||||
const { kind } = resolvedStatus('save', p, outcome);
|
||||
if (outcome?.kind === 'refused' || outcome?.kind === 'failed') return;
|
||||
const live = outcome?.kind === 'kept' ? outcome.active : p.active;
|
||||
const canTurnOn = p.trigger.kind !== 'manual' && p.canActivate;
|
||||
|
||||
expect(['saved', 'saved-live', 'saved-manual', 'saved-locked']).toContain(kind);
|
||||
expect(kind === 'saved-live').toBe(live);
|
||||
expect(kind === 'saved').toBe(!live && canTurnOn);
|
||||
expect(kind === 'saved-manual').toBe(!live && p.trigger.kind === 'manual');
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the answer itself as a wait for the result', () => {
|
||||
fc.assert(
|
||||
fc.property(decisionArb, (decision) => {
|
||||
expect(toolOutcome({ result: decision })).toEqual(WAITING);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type InstanceAiConfirmRequest,
|
||||
} from '@n8n/api-types';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { CapabilityDecision } from '@/features/ai/shared/agentsChat/resolvedCards';
|
||||
|
||||
/**
|
||||
* Pure rules for the card of `propose_automation`. The component only renders what these
|
||||
|
|
@ -31,6 +32,12 @@ export type AutomationTriggerLine =
|
|||
| { key: BaseTextKey }
|
||||
| { key: BaseTextKey; cron: string; timezone?: string; fallbackKey: BaseTextKey };
|
||||
|
||||
/**
|
||||
* A full line for the open card ("Runs at 08:00"), or a clause that goes after "runs" in the
|
||||
* sentence of an answered card ("at 08:00").
|
||||
*/
|
||||
export type AutomationTriggerForm = 'line' | 'clause';
|
||||
|
||||
/** Where the workflow runs after the answer, and why the card recommends that place. */
|
||||
export interface AutomationPlace {
|
||||
/** True when the workflow runs on a linked instance, not on this computer. */
|
||||
|
|
@ -44,13 +51,37 @@ export interface AutomationPlace {
|
|||
|
||||
export const LOCAL_CAVEAT_KEY: BaseTextKey = 'instanceAi.automation.place.localCaveat';
|
||||
|
||||
const TRIGGER_KEYS: Record<Exclude<AutomationTriggerKind, 'manual'>, BaseTextKey> = {
|
||||
schedule: 'instanceAi.automation.trigger.schedule',
|
||||
webhook: 'instanceAi.automation.trigger.webhook',
|
||||
form: 'instanceAi.automation.trigger.form',
|
||||
chat: 'instanceAi.automation.trigger.chat',
|
||||
'app-event': 'instanceAi.automation.trigger.appEvent',
|
||||
other: 'instanceAi.automation.trigger.other',
|
||||
interface TriggerKeys {
|
||||
kinds: Record<Exclude<AutomationTriggerKind, 'manual'>, BaseTextKey>;
|
||||
cron: BaseTextKey;
|
||||
cronWithTimezone: BaseTextKey;
|
||||
}
|
||||
|
||||
const TRIGGER_KEYS: Record<AutomationTriggerForm, TriggerKeys> = {
|
||||
line: {
|
||||
kinds: {
|
||||
schedule: 'instanceAi.automation.trigger.schedule',
|
||||
webhook: 'instanceAi.automation.trigger.webhook',
|
||||
form: 'instanceAi.automation.trigger.form',
|
||||
chat: 'instanceAi.automation.trigger.chat',
|
||||
'app-event': 'instanceAi.automation.trigger.appEvent',
|
||||
other: 'instanceAi.automation.trigger.other',
|
||||
},
|
||||
cron: 'instanceAi.automation.trigger.cron',
|
||||
cronWithTimezone: 'instanceAi.automation.trigger.cronWithTimezone',
|
||||
},
|
||||
clause: {
|
||||
kinds: {
|
||||
schedule: 'instanceAi.automation.resolved.trigger.schedule',
|
||||
webhook: 'instanceAi.automation.resolved.trigger.webhook',
|
||||
form: 'instanceAi.automation.resolved.trigger.form',
|
||||
chat: 'instanceAi.automation.resolved.trigger.chat',
|
||||
'app-event': 'instanceAi.automation.resolved.trigger.appEvent',
|
||||
other: 'instanceAi.automation.resolved.trigger.other',
|
||||
},
|
||||
cron: 'instanceAi.automation.resolved.trigger.cron',
|
||||
cronWithTimezone: 'instanceAi.automation.resolved.trigger.cronWithTimezone',
|
||||
},
|
||||
};
|
||||
|
||||
/** Reasons that explain a local place. The other reasons add nothing that the user can act on. */
|
||||
|
|
@ -70,16 +101,20 @@ const ALWAYS_ON_KINDS: ReadonlySet<AutomationTriggerKind> = new Set([
|
|||
'app-event',
|
||||
]);
|
||||
|
||||
/** The trigger line. A manual workflow has none. */
|
||||
export function triggerLineKey(trigger: Trigger): AutomationTriggerLine | undefined {
|
||||
/** The trigger line, or the trigger clause. A manual workflow has none. */
|
||||
export function triggerLineKey(
|
||||
trigger: Trigger,
|
||||
form: AutomationTriggerForm = 'line',
|
||||
): AutomationTriggerLine | undefined {
|
||||
if (trigger.kind === 'manual') return undefined;
|
||||
const kindKey = TRIGGER_KEYS[trigger.kind];
|
||||
const keys = TRIGGER_KEYS[form];
|
||||
const kindKey = keys.kinds[trigger.kind];
|
||||
if (trigger.cron === undefined) return { key: kindKey };
|
||||
if (trigger.timezone === undefined) {
|
||||
return { key: 'instanceAi.automation.trigger.cron', cron: trigger.cron, fallbackKey: kindKey };
|
||||
return { key: keys.cron, cron: trigger.cron, fallbackKey: kindKey };
|
||||
}
|
||||
return {
|
||||
key: 'instanceAi.automation.trigger.cronWithTimezone',
|
||||
key: keys.cronWithTimezone,
|
||||
cron: trigger.cron,
|
||||
timezone: trigger.timezone,
|
||||
fallbackKey: kindKey,
|
||||
|
|
@ -276,6 +311,15 @@ export function decisionFor(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The button behind an answer: the inverse of `decisionFor`. As on the server, only
|
||||
* `activate: true` turns the workflow on, so an approval without it saves the workflow.
|
||||
*/
|
||||
export function actionOf(decision: CapabilityDecision): AutomationAction {
|
||||
if (!decision.approved) return 'decline';
|
||||
return decision.values?.activate === true ? 'activate' : 'save';
|
||||
}
|
||||
|
||||
/** The steps that get an icon. The schema already limits them; the cap keeps the row short. */
|
||||
export function visibleSteps(proposal: Proposal): Step[] {
|
||||
return proposal.steps.slice(0, AUTOMATION_PROPOSAL_LIMITS.steps);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import type { AutomationProposalCard } from '@n8n/api-types';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
import {
|
||||
automationResultOf,
|
||||
type AutomationResult,
|
||||
} from '@/features/ai/shared/agentsChat/automationResult';
|
||||
import { triggerLineKey, type AutomationAction } from './automationProposal';
|
||||
|
||||
/**
|
||||
* Pure rules for an answered automation card. The answer says what the user chose. The tool
|
||||
* result says what happened, so it wins.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the tool step of an answered card says now. `failed` is a tool call that ended in an
|
||||
* error, so the card cannot tell what the server changed.
|
||||
*/
|
||||
export type AutomationToolOutcome = AutomationResult | { kind: 'waiting' } | { kind: 'failed' };
|
||||
|
||||
/** The tool call fields that the outcome reads (the Assistant thread mirror has this shape). */
|
||||
export interface AutomationToolCall {
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type AutomationResolvedKind =
|
||||
| 'on'
|
||||
| 'changes-live'
|
||||
| 'turning-on'
|
||||
| 'making-live'
|
||||
| 'not-on'
|
||||
| 'not-live'
|
||||
| 'saved'
|
||||
| 'saved-live'
|
||||
| 'saved-manual'
|
||||
| 'saved-locked'
|
||||
| 'not-saved'
|
||||
| 'failed'
|
||||
| 'declined';
|
||||
|
||||
/** `pending` waits for the tool result. `neutral` ends the card without a result to report. */
|
||||
export type AutomationResolvedTone = 'success' | 'warning' | 'pending' | 'neutral';
|
||||
|
||||
export interface AutomationResolvedStatus {
|
||||
kind: AutomationResolvedKind;
|
||||
messageKey: BaseTextKey;
|
||||
tone: AutomationResolvedTone;
|
||||
/** True when the workflow can be there to open, so "Open workflow" leads to it. */
|
||||
showsLink: boolean;
|
||||
}
|
||||
|
||||
type ResolvedView = Omit<AutomationResolvedStatus, 'kind'>;
|
||||
|
||||
const RESOLVED_VIEWS: Record<AutomationResolvedKind, ResolvedView> = {
|
||||
on: { messageKey: 'instanceAi.automation.resolved.on', tone: 'success', showsLink: true },
|
||||
'changes-live': {
|
||||
messageKey: 'instanceAi.automation.resolved.changesLive',
|
||||
tone: 'success',
|
||||
showsLink: true,
|
||||
},
|
||||
'turning-on': {
|
||||
messageKey: 'instanceAi.automation.resolved.turningOn',
|
||||
tone: 'pending',
|
||||
showsLink: true,
|
||||
},
|
||||
'making-live': {
|
||||
messageKey: 'instanceAi.automation.resolved.makingLive',
|
||||
tone: 'pending',
|
||||
showsLink: true,
|
||||
},
|
||||
'not-on': {
|
||||
messageKey: 'instanceAi.automation.resolved.notOn',
|
||||
tone: 'warning',
|
||||
showsLink: true,
|
||||
},
|
||||
'not-live': {
|
||||
messageKey: 'instanceAi.automation.resolved.notLive',
|
||||
tone: 'warning',
|
||||
showsLink: true,
|
||||
},
|
||||
saved: { messageKey: 'instanceAi.automation.resolved.saved', tone: 'success', showsLink: true },
|
||||
'saved-live': {
|
||||
messageKey: 'instanceAi.automation.resolved.savedLive',
|
||||
tone: 'success',
|
||||
showsLink: true,
|
||||
},
|
||||
'saved-manual': {
|
||||
messageKey: 'instanceAi.automation.resolved.savedManual',
|
||||
tone: 'success',
|
||||
showsLink: true,
|
||||
},
|
||||
'saved-locked': {
|
||||
messageKey: 'instanceAi.automation.resolved.savedLocked',
|
||||
tone: 'success',
|
||||
showsLink: true,
|
||||
},
|
||||
'not-saved': {
|
||||
messageKey: 'instanceAi.automation.resolved.notSaved',
|
||||
tone: 'warning',
|
||||
showsLink: false,
|
||||
},
|
||||
failed: { messageKey: 'instanceAi.automation.resolved.failed', tone: 'warning', showsLink: true },
|
||||
declined: {
|
||||
messageKey: 'instanceAi.automation.resolved.declined',
|
||||
tone: 'neutral',
|
||||
showsLink: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** A workflow without a trigger line has no clause for "runs …". */
|
||||
const NO_TRIGGER_KEYS: Partial<Record<AutomationResolvedKind, BaseTextKey>> = {
|
||||
on: 'instanceAi.automation.resolved.onNoTrigger',
|
||||
'changes-live': 'instanceAi.automation.resolved.changesLiveNoTrigger',
|
||||
};
|
||||
|
||||
/**
|
||||
* The outcome of the tool step. Right after the answer, the chat puts the answer in the place
|
||||
* of the result until the server sends it, so only a parsed result counts. A failed call can
|
||||
* come before or after the server kept the workflow: the server refuses most requests before
|
||||
* its first change, but an unexpected error while it turns the workflow on comes after it.
|
||||
*/
|
||||
export function toolOutcome(call: AutomationToolCall): AutomationToolOutcome {
|
||||
if (call.error !== undefined) return { kind: 'failed' };
|
||||
return automationResultOf(call.result) ?? { kind: 'waiting' };
|
||||
}
|
||||
|
||||
/** "Make changes live" is "Turn it on" for a workflow that was on already. */
|
||||
function successKind(proposal: AutomationProposalCard): AutomationResolvedKind {
|
||||
return proposal.active ? 'changes-live' : 'on';
|
||||
}
|
||||
|
||||
function waitingKind(proposal: AutomationProposalCard): AutomationResolvedKind {
|
||||
return proposal.active ? 'making-live' : 'turning-on';
|
||||
}
|
||||
|
||||
/** An outcome that leaves the answer to decide: no refusal and no failed call. */
|
||||
type OpenOutcome = Exclude<AutomationToolOutcome, { kind: 'refused' | 'failed' }>;
|
||||
|
||||
function activateKind(
|
||||
proposal: AutomationProposalCard,
|
||||
outcome: OpenOutcome | undefined,
|
||||
): AutomationResolvedKind {
|
||||
// Without a tool step to read, the card trusts the answer.
|
||||
if (outcome === undefined) return successKind(proposal);
|
||||
if (outcome.kind === 'waiting') return waitingKind(proposal);
|
||||
if (outcome.active && !outcome.failed) return successKind(proposal);
|
||||
// A failed publish can leave the version that was live before running.
|
||||
return outcome.active ? 'not-live' : 'not-on';
|
||||
}
|
||||
|
||||
/**
|
||||
* The state after "Save". It keeps a live version running: the result says if one is live, else
|
||||
* the card does. Only "saved" tells the user to turn the workflow on. A manual workflow has no
|
||||
* trigger, and the card says that it cannot turn on a locked workflow, so they get other copy.
|
||||
*/
|
||||
function saveKind(
|
||||
proposal: AutomationProposalCard,
|
||||
outcome: OpenOutcome | undefined,
|
||||
): AutomationResolvedKind {
|
||||
const live = outcome?.kind === 'kept' ? outcome.active : proposal.active;
|
||||
if (live) return 'saved-live';
|
||||
if (proposal.trigger.kind === 'manual') return 'saved-manual';
|
||||
return proposal.canActivate ? 'saved' : 'saved-locked';
|
||||
}
|
||||
|
||||
function resolvedKind(
|
||||
action: AutomationAction,
|
||||
proposal: AutomationProposalCard,
|
||||
outcome: AutomationToolOutcome | undefined,
|
||||
): AutomationResolvedKind {
|
||||
if (action === 'decline') return 'declined';
|
||||
if (outcome?.kind === 'refused') return 'not-saved';
|
||||
if (outcome?.kind === 'failed') return 'failed';
|
||||
return action === 'save' ? saveKind(proposal, outcome) : activateKind(proposal, outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of an answered card. `outcome` is undefined when the card cannot read its tool
|
||||
* step; then the card shows what the answer asked for.
|
||||
*/
|
||||
export function resolvedStatus(
|
||||
action: AutomationAction,
|
||||
proposal: AutomationProposalCard,
|
||||
outcome?: AutomationToolOutcome,
|
||||
): AutomationResolvedStatus {
|
||||
const kind = resolvedKind(action, proposal, outcome);
|
||||
const view = RESOLVED_VIEWS[kind];
|
||||
const noTriggerKey = NO_TRIGGER_KEYS[kind];
|
||||
if (noTriggerKey !== undefined && triggerLineKey(proposal.trigger) === undefined) {
|
||||
return { kind, ...view, messageKey: noTriggerKey };
|
||||
}
|
||||
return { kind, ...view };
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import lowerFirst from 'lodash/lowerFirst';
|
||||
import type { AutomationProposalCard } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { describeSchedule } from '@/features/agents/utils/scheduleBuilder';
|
||||
import {
|
||||
timezoneLabel,
|
||||
triggerLineKey,
|
||||
type AutomationPlace,
|
||||
type AutomationTriggerForm,
|
||||
} from './automationProposal';
|
||||
|
||||
/**
|
||||
* Translated text of the automation card that more than one part of the card shows. The rules
|
||||
* stay in `automationProposal.ts`; these functions only turn their keys into text.
|
||||
*/
|
||||
|
||||
/** "Runs at 08:00, Monday through Friday (United Kingdom Time)", or the clause without "Runs". */
|
||||
export function triggerText(
|
||||
trigger: AutomationProposalCard['trigger'],
|
||||
form: AutomationTriggerForm = 'line',
|
||||
): string | undefined {
|
||||
const i18n = useI18n();
|
||||
const line = triggerLineKey(trigger, form);
|
||||
if (!line) return undefined;
|
||||
if (!('cron' in line)) return i18n.baseText(line.key);
|
||||
const description = describeSchedule(line.cron);
|
||||
if (!description) return i18n.baseText(line.fallbackKey);
|
||||
const timezone = line.timezone === undefined ? '' : timezoneLabel(line.timezone, i18n.locale);
|
||||
// cronstrue starts with a capital ("At 08:00"), and the copy puts it mid-sentence.
|
||||
return i18n.baseText(line.key, {
|
||||
interpolate: { description: lowerFirst(description), timezone },
|
||||
});
|
||||
}
|
||||
|
||||
/** "This computer", the label of a linked instance, or a name in words when it has none. */
|
||||
export function placeName(place: AutomationPlace): string {
|
||||
const i18n = useI18n();
|
||||
if (!place.linked) return i18n.baseText('instanceAi.automation.place.thisComputer');
|
||||
return place.linkedLabel ?? i18n.baseText('instanceAi.automation.place.otherInstance');
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import type { SharedCard } from '@n8n/api-types';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { AssistantConfirmationInput } from '@/features/ai/shared/agentsChat/assistantConfirmation';
|
||||
import { makeProposal } from '../../components/automation/__tests__/automationProposalFixtures';
|
||||
import SharedThreadCard from '../SharedThreadCard.vue';
|
||||
import { provideThreadSharing } from '../useThreadSharing';
|
||||
import { PROJECT_ID, TEAMMATE, THREAD_ID, setUpSharing } from './sharingFixtures';
|
||||
|
||||
const proposal = makeProposal();
|
||||
const input: AssistantConfirmationInput = {
|
||||
requestId: 'req-1',
|
||||
message: 'Want "Morning digest" to run automatically?',
|
||||
capability: true,
|
||||
automationProposal: proposal,
|
||||
};
|
||||
const call: SharedCard = {
|
||||
toolName: 'propose_automation',
|
||||
input: { workflowId: proposal.workflowId },
|
||||
suspendPayload: { requestId: 'req-1', automationProposal: proposal, offered: proposal.offered },
|
||||
};
|
||||
|
||||
function renderCard(props: { resolvedValue?: unknown; disabled?: boolean } = {}) {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
provideThreadSharing({ id: THREAD_ID, projectId: PROJECT_ID }, () => []);
|
||||
return () =>
|
||||
h(SharedThreadCard, { input, call, toolCallId: 'tc-1', ...props, onSubmit: vi.fn() });
|
||||
},
|
||||
});
|
||||
return createComponentRenderer(Host)();
|
||||
}
|
||||
|
||||
// A teammate who answers an automation card sees its outcome, as the owner does.
|
||||
describe('SharedThreadCard with an automation card seen by a teammate', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
setUpSharing({ shared: true, viewerId: TEAMMATE.id });
|
||||
});
|
||||
|
||||
it('offers the proposal and says who the answer runs as', () => {
|
||||
const { getByTestId, queryByTestId } = renderCard();
|
||||
|
||||
expect(getByTestId('automation-proposal-card')).toBeInTheDocument();
|
||||
expect(getByTestId('instance-ai-shared-card-footer')).toHaveTextContent('Runs as Alice Owner');
|
||||
expect(queryByTestId('automation-proposal-resolved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the outcome of the answer, without the footer', () => {
|
||||
const { getByTestId, queryByTestId } = renderCard({
|
||||
resolvedValue: { kind: 'capabilityDecision', approved: false },
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
expect(getByTestId('automation-proposal-resolved')).toHaveTextContent('Not automated.');
|
||||
expect(queryByTestId('automation-proposal-card')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('instance-ai-shared-card-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
|
||||
import { automationResultOf, summariseAutomationResult } from '../automationResult';
|
||||
|
||||
const RESULT = { workflowId: 'wf-1', url: 'http://localhost:5678/workflow/wf-1', kept: true };
|
||||
const ON = { ...RESULT, active: true };
|
||||
const OFF = { ...RESULT, active: false };
|
||||
const ERROR = 'Saved "Morning digest", but could not turn it on: x';
|
||||
const DENIED = { denied: true, message: 'The user declined this action.' };
|
||||
const TURN_ON = { kind: 'capabilityDecision', approved: true, values: { activate: true } };
|
||||
|
||||
describe('automationResultOf', () => {
|
||||
it.each([
|
||||
['a workflow that is on', ON, { kind: 'kept', active: true, failed: false }],
|
||||
['a saved workflow that is off', OFF, { kind: 'kept', active: false, failed: false }],
|
||||
['a failed publish', { ...OFF, error: ERROR }, { kind: 'kept', active: false, failed: true }],
|
||||
['a declined or blocked answer', DENIED, { kind: 'refused' }],
|
||||
['"denied" without a message', { denied: true }, { kind: 'refused' }],
|
||||
])('reads %s', (_name, output, expected) => {
|
||||
expect(automationResultOf(output)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summariseAutomationResult', () => {
|
||||
it.each([
|
||||
['a workflow that is on', ON, 'instanceAi.automation.summary.on'],
|
||||
['a saved workflow that is off', OFF, 'instanceAi.automation.summary.off'],
|
||||
[
|
||||
'a workflow that could not be turned on',
|
||||
{ ...OFF, error: ERROR },
|
||||
'instanceAi.automation.summary.notOn',
|
||||
],
|
||||
[
|
||||
'a live workflow whose changes are not live',
|
||||
{ ...ON, error: ERROR },
|
||||
'instanceAi.automation.summary.notLive',
|
||||
],
|
||||
['a declined or blocked answer', DENIED, 'instanceAi.automation.summary.declined'],
|
||||
[
|
||||
'a result with warnings',
|
||||
{ ...ON, warnings: ['Ignored the cron'] },
|
||||
'instanceAi.automation.summary.on',
|
||||
],
|
||||
])('summarises %s', (_name, output, key) => {
|
||||
expect(summariseAutomationResult(output)).toBe(key);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no output', undefined],
|
||||
['null', null],
|
||||
['text', 'It is on'],
|
||||
['an empty object', {}],
|
||||
['only "active"', { active: true }],
|
||||
['a result that was not kept', { ...ON, kept: false }],
|
||||
['"active" as text', { ...RESULT, active: 'true' }],
|
||||
['"denied" as text', { denied: 'true' }],
|
||||
['the answer itself', TURN_ON],
|
||||
])('has no summary for %s', (_name, output) => {
|
||||
expect(automationResultOf(output)).toBeUndefined();
|
||||
expect(summariseAutomationResult(output)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('automation result properties', () => {
|
||||
const decisionArb = fc
|
||||
.record({
|
||||
approved: fc.boolean(),
|
||||
values: fc.option(
|
||||
fc.dictionary(
|
||||
fc.constantFrom('activate', 'target'),
|
||||
fc.oneof(fc.boolean(), fc.constantFrom('local', 'true')),
|
||||
),
|
||||
{ nil: undefined },
|
||||
),
|
||||
})
|
||||
.map((fields) => ({ kind: 'capabilityDecision' as const, ...fields }));
|
||||
|
||||
/** Results with any mix of the fields that the summary reads. */
|
||||
const resultLikeArb = fc.record(
|
||||
{
|
||||
workflowId: fc.constantFrom('wf-1', 7),
|
||||
url: fc.constantFrom('http://localhost/workflow/wf-1', null),
|
||||
active: fc.constantFrom(true, false, 'true', 1),
|
||||
kept: fc.constantFrom(true, false, 'true'),
|
||||
error: fc.constantFrom('Could not publish', 42),
|
||||
denied: fc.constantFrom(true, false, 'true'),
|
||||
},
|
||||
{ requiredKeys: [] },
|
||||
);
|
||||
|
||||
it('gives the answer itself no summary, so the step waits for the result', () => {
|
||||
fc.assert(
|
||||
fc.property(decisionArb, (decision) => {
|
||||
expect(automationResultOf(decision)).toBeUndefined();
|
||||
expect(summariseAutomationResult(decision)).toBeUndefined();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('never says "on" unless the result is a kept workflow that is live without an error', () => {
|
||||
fc.assert(
|
||||
fc.property(resultLikeArb, (output) => {
|
||||
const isOn =
|
||||
output.denied !== true &&
|
||||
typeof output.workflowId === 'string' &&
|
||||
typeof output.url === 'string' &&
|
||||
output.kept === true &&
|
||||
output.active === true &&
|
||||
output.error === undefined;
|
||||
|
||||
expect(summariseAutomationResult(output) === 'instanceAi.automation.summary.on').toBe(isOn);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('says "Not automated" exactly when the result is denied', () => {
|
||||
fc.assert(
|
||||
fc.property(resultLikeArb, (output) => {
|
||||
const declined =
|
||||
summariseAutomationResult(output) === 'instanceAi.automation.summary.declined';
|
||||
|
||||
expect(declined).toBe(output.denied === true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { APPROVAL_TOOL_NAME, N8N_CHAT_ACTION_TOOL_NAME, WAIT_TOOL_NAME } from '@n8n/api-types';
|
||||
|
||||
import { makeProposal } from '@/features/ai/instanceAi/components/automation/__tests__/automationProposalFixtures';
|
||||
import { ASSISTANT_CONFIRMATION_TOOL_NAME } from '../assistantConfirmation';
|
||||
import { capabilityDecisionOf, keepSessionAnswers, keepsResolvedCard } from '../resolvedCards';
|
||||
import type { ChatMessage, InteractivePayload } from '../types';
|
||||
|
||||
const TURN_ON = { kind: 'capabilityDecision', approved: true, values: { activate: true } };
|
||||
const SAVE = {
|
||||
kind: 'capabilityDecision',
|
||||
approved: true,
|
||||
values: { target: 'local', activate: false },
|
||||
};
|
||||
const DECLINE = { kind: 'capabilityDecision', approved: false };
|
||||
const TOOL_RESULT = { workflowId: 'wf-1', url: '/workflow/wf-1', active: true, kept: true };
|
||||
|
||||
function automationCard(overrides: Partial<InteractivePayload> = {}): InteractivePayload {
|
||||
return {
|
||||
toolName: ASSISTANT_CONFIRMATION_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
resolvedAt: 1,
|
||||
resolvedValue: TURN_ON,
|
||||
input: {
|
||||
requestId: 'r-1',
|
||||
message: 'Want "Morning digest" to run automatically?',
|
||||
capability: true,
|
||||
automationProposal: makeProposal(),
|
||||
},
|
||||
...overrides,
|
||||
} as InteractivePayload;
|
||||
}
|
||||
|
||||
describe('capabilityDecisionOf', () => {
|
||||
it.each([TURN_ON, SAVE, DECLINE])('reads a capability answer: %j', (answer) => {
|
||||
expect(capabilityDecisionOf(answer)).toEqual(answer);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an approval', { kind: 'approval', approved: true }],
|
||||
['a tool result', TOOL_RESULT],
|
||||
['a declined tool result', { denied: true, message: 'The user declined.' }],
|
||||
['an answer without "approved"', { kind: 'capabilityDecision' }],
|
||||
['an answer with a value that is not a string or a boolean', { ...SAVE, values: { n: 1 } }],
|
||||
['no value', undefined],
|
||||
['a string', 'capabilityDecision'],
|
||||
])('returns undefined for %s', (_name, value) => {
|
||||
expect(capabilityDecisionOf(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepsResolvedCard', () => {
|
||||
it.each([
|
||||
['Turn it on', TURN_ON],
|
||||
['Save, but leave it off', SAVE],
|
||||
['Not now', DECLINE],
|
||||
])('keeps an automation card answered with "%s"', (_name, answer) => {
|
||||
expect(keepsResolvedCard(automationCard({ resolvedValue: answer }))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves an open automation card to the rule for open cards', () => {
|
||||
expect(keepsResolvedCard(automationCard({ resolvedAt: undefined, runId: 'run-1' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('hides an automation card that was cancelled after its answer', () => {
|
||||
expect(keepsResolvedCard(automationCard({ cancelled: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the tool result, as after a reload', TOOL_RESULT],
|
||||
['a declined tool result', { denied: true, message: 'The user declined.' }],
|
||||
['an approval body', { kind: 'approval', approved: true }],
|
||||
['no answer', undefined],
|
||||
])('hides an answered automation card whose value is %s', (_name, value) => {
|
||||
expect(keepsResolvedCard(automationCard({ resolvedValue: value }))).toBe(false);
|
||||
});
|
||||
|
||||
it('hides an answered capability card that is not an automation card', () => {
|
||||
const payload = automationCard({
|
||||
input: { requestId: 'r-2', message: 'Run "Morning digest"?', capability: true },
|
||||
});
|
||||
|
||||
expect(keepsResolvedCard(payload)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides answered approval and question cards, as before', () => {
|
||||
const approval: InteractivePayload = {
|
||||
toolName: APPROVAL_TOOL_NAME,
|
||||
toolCallId: 'tc-2',
|
||||
resolvedAt: 1,
|
||||
input: { type: 'approval', toolName: 'send_message', args: {} },
|
||||
resolvedValue: { approved: true },
|
||||
};
|
||||
const questions = automationCard({
|
||||
input: { requestId: 'r-3', message: 'Which one?', inputType: 'questions', questions: [] },
|
||||
resolvedValue: { kind: 'questions', answers: [] },
|
||||
});
|
||||
|
||||
expect(keepsResolvedCard(approval)).toBe(false);
|
||||
expect(keepsResolvedCard(questions)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepsResolvedCard properties', () => {
|
||||
const decisions = fc
|
||||
.record({ approved: fc.boolean(), activate: fc.option(fc.boolean(), { nil: undefined }) })
|
||||
.map(({ approved, activate }) => ({
|
||||
kind: 'capabilityDecision',
|
||||
approved,
|
||||
...(activate !== undefined && { values: { target: 'local', activate } }),
|
||||
}));
|
||||
|
||||
const otherValues = fc.constantFrom<unknown>(
|
||||
undefined,
|
||||
null,
|
||||
'approve',
|
||||
42,
|
||||
{},
|
||||
{ approved: true },
|
||||
{ kind: 'approval', approved: false },
|
||||
{ kind: 'questions', answers: [] },
|
||||
{ type: 'button', value: 'approve' },
|
||||
TOOL_RESULT,
|
||||
{ ...TOOL_RESULT, active: false, error: 'Could not publish' },
|
||||
{ denied: true, message: 'Blocked by an admin' },
|
||||
);
|
||||
|
||||
/** A resolved value, and whether it is a capability answer. */
|
||||
const answers = fc.oneof(
|
||||
decisions.map((value) => ({ value, isDecision: true })),
|
||||
otherValues.map((value) => ({ value, isDecision: false })),
|
||||
);
|
||||
|
||||
const state = fc.record({
|
||||
toolCallId: fc.constantFrom('tc-1', 'tc-2'),
|
||||
runId: fc.option(fc.constant('run-1'), { nil: undefined }),
|
||||
resolvedAt: fc.option(fc.integer({ min: 0, max: 5 }), { nil: undefined }),
|
||||
cancelled: fc.option(fc.boolean(), { nil: undefined }),
|
||||
});
|
||||
|
||||
const otherAssistantInputs = fc.constantFrom(
|
||||
{ requestId: 'r', message: 'Sure?', severity: 'warning' as const },
|
||||
{ requestId: 'r', message: 'Run it?', capability: true },
|
||||
{ requestId: 'r', message: 'Which?', inputType: 'questions' as const, questions: [] },
|
||||
{ requestId: 'r', message: 'Allow?', domainAccess: { url: 'https://a.test', host: 'a.test' } },
|
||||
);
|
||||
|
||||
/** Every card without an automation proposal, from every renderer. */
|
||||
const cardsWithoutProposal: fc.Arbitrary<InteractivePayload> = fc.oneof(
|
||||
fc.tuple(state, otherAssistantInputs, answers).map(
|
||||
([base, input, answer]): InteractivePayload => ({
|
||||
...base,
|
||||
toolName: ASSISTANT_CONFIRMATION_TOOL_NAME,
|
||||
input,
|
||||
resolvedValue: answer.value,
|
||||
}),
|
||||
),
|
||||
fc
|
||||
.tuple(state, fc.option(fc.record({ approved: fc.boolean() }), { nil: undefined }))
|
||||
.map(([base, resolvedValue]) => ({
|
||||
...base,
|
||||
toolName: APPROVAL_TOOL_NAME,
|
||||
input: { type: 'approval' as const, toolName: 'send_message', args: {} },
|
||||
resolvedValue,
|
||||
})),
|
||||
fc.tuple(state, fc.constantFrom(N8N_CHAT_ACTION_TOOL_NAME, WAIT_TOOL_NAME)).map(
|
||||
([base, toolName]) =>
|
||||
({
|
||||
...base,
|
||||
toolName,
|
||||
input: { card: { components: [] } },
|
||||
}) as InteractivePayload,
|
||||
),
|
||||
);
|
||||
|
||||
it('never changes the rule for a card without an automation proposal', () => {
|
||||
fc.assert(
|
||||
fc.property(cardsWithoutProposal, (payload) => {
|
||||
expect(keepsResolvedCard(payload)).toBe(false);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an automation card only when it is answered, not cancelled, with a capability answer', () => {
|
||||
fc.assert(
|
||||
fc.property(state, answers, (base, answer) => {
|
||||
const payload = automationCard({ ...base, resolvedValue: answer.value });
|
||||
const expected = Boolean(base.resolvedAt) && base.cancelled !== true && answer.isDecision;
|
||||
|
||||
expect(keepsResolvedCard(payload)).toBe(expected);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepSessionAnswers', () => {
|
||||
/** A message as a history refresh builds it: the answered card holds the tool result. */
|
||||
function refreshed(...interactives: InteractivePayload[]): ChatMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
status: 'success',
|
||||
interactives,
|
||||
interactive: interactives[0],
|
||||
};
|
||||
}
|
||||
|
||||
const answers = (entries: Array<[string, unknown]>) => new Map<string, unknown>(entries);
|
||||
|
||||
it('puts the answer of this session back on an answered automation card', () => {
|
||||
const message = refreshed(automationCard({ resolvedValue: TOOL_RESULT }));
|
||||
|
||||
keepSessionAnswers([message], answers([['tc-1', SAVE]]));
|
||||
|
||||
expect(message.interactives?.[0].resolvedValue).toEqual(SAVE);
|
||||
expect(message.interactive?.resolvedValue).toEqual(SAVE);
|
||||
expect(keepsResolvedCard(message.interactives?.[0] ?? automationCard())).toBe(true);
|
||||
});
|
||||
|
||||
it('changes only the card of the answer, and keeps the others as the history has them', () => {
|
||||
const other = automationCard({ toolCallId: 'tc-2', resolvedValue: TOOL_RESULT });
|
||||
const message = refreshed(automationCard({ resolvedValue: TOOL_RESULT }), other);
|
||||
|
||||
keepSessionAnswers([message], answers([['tc-1', TURN_ON]]));
|
||||
|
||||
expect(message.interactives?.map((card) => card.resolvedValue)).toEqual([TURN_ON, TOOL_RESULT]);
|
||||
expect(message.interactives?.[1]).toBe(other);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['is open again', { resolvedAt: undefined, resolvedValue: undefined, runId: 'run-1' }],
|
||||
['was cancelled', { cancelled: true, resolvedValue: undefined }],
|
||||
])('leaves a card that %s as the history has it', (_name, fields) => {
|
||||
const card = automationCard(fields);
|
||||
const message = refreshed(card);
|
||||
|
||||
keepSessionAnswers([message], answers([['tc-1', TURN_ON]]));
|
||||
|
||||
expect(message.interactives?.[0]).toBe(card);
|
||||
});
|
||||
|
||||
it('leaves other Assistant cards with the value from the history', () => {
|
||||
const questions = automationCard({
|
||||
input: { requestId: 'r-3', message: 'Which one?', inputType: 'questions', questions: [] },
|
||||
resolvedValue: { answered: true },
|
||||
});
|
||||
const message = refreshed(questions);
|
||||
|
||||
keepSessionAnswers([message], answers([['tc-1', { kind: 'questions', answers: [] }]]));
|
||||
|
||||
expect(message.interactives?.[0]).toBe(questions);
|
||||
});
|
||||
|
||||
it('does nothing without answers, and leaves messages without cards alone', () => {
|
||||
const card = automationCard({ resolvedValue: TOOL_RESULT });
|
||||
const message = refreshed(card);
|
||||
const plain: ChatMessage = { id: 'user-1', role: 'user', content: 'Hi', status: 'success' };
|
||||
const messages = [plain, message];
|
||||
|
||||
expect(keepSessionAnswers(messages, new Map())).toBe(messages);
|
||||
expect(keepSessionAnswers(messages, answers([['tc-1', TURN_ON]]))).toBe(messages);
|
||||
expect(plain).toEqual({ id: 'user-1', role: 'user', content: 'Hi', status: 'success' });
|
||||
});
|
||||
|
||||
it('changes a card only when the answer keeps it in the chat', () => {
|
||||
const card = automationCard({ resolvedValue: TOOL_RESULT });
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.constantFrom<unknown>(TURN_ON, SAVE, DECLINE),
|
||||
fc.constantFrom<unknown>(undefined, 'yes', TOOL_RESULT, { kind: 'approval' }),
|
||||
),
|
||||
(answer) => {
|
||||
const message = refreshed(card);
|
||||
keepSessionAnswers([message], answers([['tc-1', answer]]));
|
||||
const kept = keepsResolvedCard({ ...card, resolvedValue: answer } as InteractivePayload);
|
||||
|
||||
expect(message.interactives?.[0].resolvedValue).toEqual(kept ? answer : TOOL_RESULT);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { automationProposalResultSchema } from '@n8n/api-types';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
/**
|
||||
* Pure rules for the result of the n8n Assistant tool `propose_automation`. The chat summary of
|
||||
* the tool step and the answered automation card both read the result through these rules.
|
||||
*/
|
||||
|
||||
export const PROPOSE_AUTOMATION_TOOL_NAME = 'propose_automation';
|
||||
|
||||
/** What a `propose_automation` result says about the workflow. */
|
||||
export type AutomationResult =
|
||||
| { kind: 'kept'; active: boolean; failed: boolean }
|
||||
/** Declined, or blocked by an admin before the first change: nothing was kept. */
|
||||
| { kind: 'refused' };
|
||||
|
||||
/** A declined or blocked capability answers `{ denied: true, message }`. */
|
||||
function isDenied(output: unknown): boolean {
|
||||
return isRecord(output) && output.denied === true;
|
||||
}
|
||||
|
||||
/** The outcome in a tool result, or undefined for any other value (for example the answer). */
|
||||
export function automationResultOf(output: unknown): AutomationResult | undefined {
|
||||
if (isDenied(output)) return { kind: 'refused' };
|
||||
const parsed = automationProposalResultSchema.safeParse(output);
|
||||
if (!parsed.success) return undefined;
|
||||
return { kind: 'kept', active: parsed.data.active, failed: parsed.data.error !== undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary of a `propose_automation` tool step, from its result only. It names the state,
|
||||
* not the button: "Save" on a live workflow also gives `active: true`.
|
||||
*/
|
||||
export function summariseAutomationResult(output: unknown): BaseTextKey | undefined {
|
||||
const result = automationResultOf(output);
|
||||
if (result === undefined) return undefined;
|
||||
if (result.kind === 'refused') return 'instanceAi.automation.summary.declined';
|
||||
if (result.failed) {
|
||||
return result.active
|
||||
? 'instanceAi.automation.summary.notLive'
|
||||
: 'instanceAi.automation.summary.notOn';
|
||||
}
|
||||
return result.active ? 'instanceAi.automation.summary.on' : 'instanceAi.automation.summary.off';
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { N8N_CHAT_ACTION_TOOL_NAME } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
import { PROPOSE_AUTOMATION_TOOL_NAME, summariseAutomationResult } from './automationResult';
|
||||
import {
|
||||
cardChoiceLabel,
|
||||
n8nChatResumeValueSchema,
|
||||
|
|
@ -37,5 +39,12 @@ export function summariseToolCall(
|
|||
return cardChoiceLabel(parsed.card, resume.data);
|
||||
}
|
||||
|
||||
if (toolName === PROPOSE_AUTOMATION_TOOL_NAME) {
|
||||
// The tool result says what happened. Right after the answer, the output is the answer
|
||||
// itself, which has no summary until the result arrives.
|
||||
const key = summariseAutomationResult(output);
|
||||
return key === undefined ? undefined : useI18n().baseText(key);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { InstanceAiConfirmRequestDto, type InstanceAiConfirmRequest } from '@n8n/api-types';
|
||||
|
||||
import { ASSISTANT_CONFIRMATION_TOOL_NAME } from './assistantConfirmation';
|
||||
import { getMessageInteractives, setMessageInteractives } from './messageMappers';
|
||||
import type { ChatMessage, InteractivePayload } from './types';
|
||||
|
||||
/** The answer to an n8n Assistant capability card, for example the automation card. */
|
||||
export type CapabilityDecision = Extract<InstanceAiConfirmRequest, { kind: 'capabilityDecision' }>;
|
||||
|
||||
/** The capability answer in a resume value, or undefined for any other value. */
|
||||
export function capabilityDecisionOf(value: unknown): CapabilityDecision | undefined {
|
||||
const parsed = InstanceAiConfirmRequestDto.safeParse(value);
|
||||
return parsed.success && parsed.data.kind === 'capabilityDecision' ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an answered automation card that stays in the chat to show what happened. The card
|
||||
* needs the answer that the user sent in this session (see `keepSessionAnswers`). After a reload
|
||||
* the resolved value is the tool result, so the card stays hidden and its tool step shows the
|
||||
* outcome. Every other card keeps the rule of the chat.
|
||||
*/
|
||||
export function keepsResolvedCard(payload: InteractivePayload): boolean {
|
||||
if (payload.toolName !== ASSISTANT_CONFIRMATION_TOOL_NAME) return false;
|
||||
if (!payload.resolvedAt || payload.cancelled === true) return false;
|
||||
if (payload.input.automationProposal === undefined) return false;
|
||||
return capabilityDecisionOf(payload.resolvedValue) !== undefined;
|
||||
}
|
||||
|
||||
/** The card with the answer of this session, when that answer keeps the card in the chat. */
|
||||
function withSessionAnswer(
|
||||
payload: InteractivePayload,
|
||||
answers: ReadonlyMap<string, unknown>,
|
||||
): InteractivePayload {
|
||||
if (payload.toolName !== ASSISTANT_CONFIRMATION_TOOL_NAME) return payload;
|
||||
if (!answers.has(payload.toolCallId)) return payload;
|
||||
const answered = { ...payload, resolvedValue: answers.get(payload.toolCallId) };
|
||||
return keepsResolvedCard(answered) ? answered : payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the answers that the user sent in this session back on their automation cards. A history
|
||||
* refresh gives an answered card the tool result in place of the answer, so without this the
|
||||
* card would go away when the turn ends. Only a card that the answer keeps in the chat changes.
|
||||
* A card that is open again, or was cancelled, keeps its value from the history.
|
||||
*/
|
||||
export function keepSessionAnswers<T extends Pick<ChatMessage, 'interactive' | 'interactives'>>(
|
||||
messages: T[],
|
||||
answers: ReadonlyMap<string, unknown>,
|
||||
): T[] {
|
||||
if (answers.size === 0) return messages;
|
||||
for (const message of messages) {
|
||||
const interactives = getMessageInteractives(message);
|
||||
if (!interactives.some((payload) => answers.has(payload.toolCallId))) continue;
|
||||
setMessageInteractives(
|
||||
message,
|
||||
interactives.map((payload) => withSessionAnswer(payload, answers)),
|
||||
);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user