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:
Claude 2026-10-08 23:48:31 +00:00
parent c9c97502f8
commit 6f0f158619
No known key found for this signature in database
24 changed files with 2487 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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