feat(editor): Show Agent plans in preview chat (no-changelog) (#40006)

This commit is contained in:
Eugene 2026-10-05 15:42:28 +00:00 committed by GitHub
parent 5c0aff8cbc
commit 308ec2cc1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1805 additions and 32 deletions

View File

@ -62,6 +62,26 @@ beforeEach(() => {
});
describe('Agent plan tools', () => {
it('returns presentation text on creation and full-document updates', async () => {
const presentation = { label: 'Researching', detail: 'Checking three sources.' };
expect(
await call('create_plan', { document: { ...document(task()), presentation } }),
).toMatchObject({ document: { presentation } });
const { startedAt, endedAt, ...existingTask } = original.data.items[0];
const proposed = {
...document(existingTask),
presentation: { label: 'Research complete', detail: 'Found three sources.' },
};
expect(await call('update_plan', { ...write, document: proposed })).toMatchObject({
revision: 2,
document: proposed,
});
expect(service.replacePlan.mock.calls[0][0].data).toMatchObject({
presentation: proposed.presentation,
items: [{ ...existingTask, startedAt, endedAt }],
});
});
it('generates permanent IDs and resolves forward, group, and child references', async () => {
const result = await call('create_plan', {
document: document(
@ -92,19 +112,47 @@ describe('Agent plan tools', () => {
planId: input.id,
revision: 1,
closed: false,
startedAt: null,
closedAt: null,
readiness: { ready: [phase.id, phase.tasks[1].id] },
});
for (const hidden of [
'startedAt',
'endedAt',
'createdAt',
'updatedAt',
'threadId',
'formatVersion',
]) {
for (const hidden of ['endedAt', 'createdAt', 'updatedAt', 'threadId', 'formatVersion']) {
expect(JSON.stringify(result)).not.toContain(`"${hidden}"`);
}
expect(JSON.stringify(result)).not.toContain('new:');
expect(result).not.toHaveProperty('document.items.0.startedAt');
expect(result).not.toHaveProperty('document.items.1.tasks.0.startedAt');
});
it('returns the earliest stored start and the plan closure time outside the document', async () => {
const startedAt = '2026-10-01T10:00:00.000Z';
const later = '2026-10-01T10:02:00.000Z';
const closedAt = new Date('2026-10-01T10:05:00.000Z');
const plan = {
...stored(
parseAgentPlan(
document(
{ ...task(randomUUID()), startedAt: later, status: 'done', endedAt: later },
{
...task(randomUUID()),
kind: 'group',
startedAt: later,
tasks: [{ ...task(randomUUID()), startedAt, status: 'done', endedAt: later }],
},
),
1,
),
),
closedAt,
};
service.closePlan.mockResolvedValue(plan);
expect(await call('close_plan', write)).toMatchObject({
startedAt,
closedAt: closedAt.toISOString(),
closed: true,
});
service.findActivePlan.mockResolvedValue({ ...plan, closedAt: null });
expect(await call('read_plan', {})).toMatchObject({ startedAt, closedAt: null, closed: false });
});
it.each([
@ -117,6 +165,7 @@ describe('Agent plan tools', () => {
['new UUIDs', document(task(randomUUID()))],
['invalid aliases', document(task('new:'))],
['unknown fields', { ...document(task()), extra: true }],
['invalid presentation', { ...document(task()), presentation: { label: '' } }],
['caller timestamps', document({ ...task(), startedAt: null })],
[
'child timestamps',

View File

@ -100,6 +100,13 @@ function prepareDocument(
function presentPlan(plan: AgentPlanSnapshot | null) {
if (!plan) return null;
const startedAt =
plan.data.items
.flatMap((item) => (item.kind === 'group' ? [item, ...item.tasks] : [item]))
.map((item) => item.startedAt)
.filter((timestamp): timestamp is string => timestamp !== null)
.sort()[0] ?? null;
const withoutTiming = <Item extends AgentPlanItem>(item: Item) => {
const { startedAt, endedAt, ...visible } = item;
return visible;
@ -109,6 +116,8 @@ function presentPlan(plan: AgentPlanSnapshot | null) {
planId: plan.id,
revision: plan.revision,
closed: plan.closedAt !== null,
startedAt,
closedAt: plan.closedAt?.toISOString() ?? null,
document: {
...plan.data,
items: plan.data.items.map((item) =>

View File

@ -8103,6 +8103,15 @@
"agents.chat.backgroundTasks.status.completed": "Completed",
"agents.chat.backgroundTasks.status.failed": "Failed",
"agents.chat.backgroundTasks.status.cancelled": "Canceled",
"agents.chat.plan.title": "Plan: {title}",
"agents.chat.plan.progress": "{done} of {total} task done | {done} of {total} tasks done",
"agents.chat.plan.viewTrace": "View trace",
"agents.chat.plan.error.rejected": "The plan change was rejected. Expand this step for details.",
"agents.chat.plan.status.pending": "Pending",
"agents.chat.plan.status.inProgress": "In progress",
"agents.chat.plan.status.done": "Done",
"agents.chat.plan.status.failed": "Failed",
"agents.chat.plan.status.canceled": "Canceled",
"agents.chat.streamInterrupted": "The connection was interrupted. Refresh the page to see the latest response.",
"agents.chat.toolError.generic": "Something went wrong while running this tool",
"agents.chat.clearHistory": "Clear chat history",

View File

@ -1,7 +1,8 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AgentChatMessageList from '../components/AgentChatMessageList.vue';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
import { planMessage, planTask, planView } from './fixtures/agent-plan';
const copySpy = vi.fn();
@ -81,10 +82,6 @@ vi.mock('@/features/ai/shared/components/AiReasoningBlock.vue', () => ({
},
}));
vi.mock('@/features/ai/chatHub/components/ChatTypingIndicator.vue', () => ({
default: { template: '<div data-test-id="typing-indicator" />' },
}));
vi.mock('@/features/agents/components/AgentChatToolSteps.vue', () => ({
default: {
name: 'AgentChatToolSteps',
@ -135,6 +132,143 @@ vi.mock('@n8n/i18n', () => ({
}));
describe('AgentChatMessageList', () => {
it('keeps the budget notice and action when the plan progress call is hidden', async () => {
const initial = planView();
const previous = { ...planMessage(initial), content: 'Starting research.' };
const stopped: ChatMessage = {
...planMessage({ ...initial, revision: 2 }, { tool: 'update_plan' }),
status: 'success',
budgetNotices: [{ id: 'budget-stop', code: 'budget.session' }],
};
const wrapper = mount(AgentChatMessageList, {
props: {
messages: [previous, stopped],
messagingState: 'idle',
canIncreaseBudget: true,
},
});
const cards = wrapper.findAll('[data-testid="agent-budget-notice-card"]');
expect(cards).toHaveLength(1);
expect(cards[0].attributes('data-can-increase')).toBe('true');
await cards[0].get('button').trigger('click');
expect(wrapper.emitted('increase-budget')).toEqual([
[{ field: 'sessionCostCapUsd', amount: 10 }],
]);
expect(wrapper.find('[data-test-id="agent-typing-indicator"]').exists()).toBe(false);
wrapper.unmount();
});
it.each(['success', 'awaitingUser'] as const)(
'hides tool-run typing dots when the final message is %s',
async (status) => {
const initial = planView();
const previous = { ...planMessage(initial), content: 'Starting research.' };
const progress: ChatMessage = {
...planMessage(undefined, {
tool: 'update_plan',
state: 'running',
input: { planId: initial.planId, expectedRevision: 1, document: initial.document },
}),
status: 'streaming',
};
const wrapper = mount(AgentChatMessageList, {
props: { messages: [previous, progress], messagingState: 'receiving' },
});
expect(wrapper.findAll('[data-test-id="agent-typing-indicator"]')).toHaveLength(1);
const finalMessage: ChatMessage = { id: 'final', role: 'assistant', content: '', status };
await wrapper.setProps({ messages: [previous, progress, finalMessage] });
expect(wrapper.find('[data-test-id="agent-typing-indicator"]').exists()).toBe(false);
await wrapper.setProps({
messages: [previous, progress, { ...finalMessage, status: 'streaming' }],
});
expect(wrapper.findAll('[data-test-id="agent-typing-indicator"]')).toHaveLength(1);
await wrapper.setProps({
messages: [
previous,
progress,
{ ...finalMessage, status: 'streaming', content: 'Found candidates.' },
],
});
expect(wrapper.find('[data-test-id="agent-typing-indicator"]').exists()).toBe(false);
wrapper.unmount();
},
);
it('hides plan progress during streaming and completion but keeps errors and chat text', async () => {
const initial = planView();
const document = { ...initial.document, presentation: { label: 'Research candidates' } };
const previous = { ...planMessage(initial), id: 'previous', content: 'Starting research.' };
const pending: ChatMessage = {
...planMessage(undefined, {
tool: 'update_plan',
toolCallId: 'update',
state: 'running',
input: { planId: initial.planId, expectedRevision: 1, document },
}),
id: 'update',
status: 'streaming',
};
const wrapper = mount(AgentChatMessageList, {
props: { messages: [previous, pending], messagingState: 'receiving' },
});
const calls = () =>
wrapper
.findAllComponents({ name: 'AgentChatToolSteps' })
.flatMap((steps) => steps.props('toolCalls') as ToolCall[]);
expect(calls().map((call) => call.tool)).toEqual(['create_plan']);
expect(wrapper.find('[data-test-id="agent-typing-indicator"]').exists()).toBe(true);
const completed: ChatMessage = {
...pending,
status: 'success',
toolCalls: [
{ ...pending.toolCalls![0], state: 'done', output: { ...initial, revision: 2, document } },
],
};
await wrapper.setProps({ messages: [previous, completed], messagingState: 'idle' });
expect(calls().map((call) => call.tool)).toEqual(['create_plan']);
expect(wrapper.find('[data-test-id="agent-typing-indicator"]').exists()).toBe(false);
expect(wrapper.findAll('[data-testid="markdown-chunk"]')).toHaveLength(1);
await wrapper.setProps({
messages: [previous, { ...completed, content: 'Found candidates.' }],
});
expect(wrapper.text()).toContain('Found candidates.');
expect(calls().map((call) => call.tool)).toEqual(['create_plan']);
await wrapper.setProps({
messages: [
previous,
{
...completed,
toolCalls: [{ ...completed.toolCalls![0], output: { error: 'conflict' } }],
},
],
});
expect(calls().map((call) => call.tool)).toEqual(['create_plan', 'update_plan']);
await wrapper.setProps({
messages: [
previous,
{
...completed,
toolCalls: [
{
...completed.toolCalls![0],
output: {
...initial,
revision: 2,
document: { ...document, items: [...document.items, planTask(99)] },
},
},
],
},
],
});
expect(calls().map((call) => call.tool)).toEqual(['create_plan', 'update_plan']);
wrapper.unmount();
});
it('keeps signal expansion and order when the response gains tools and text', async () => {
const message: ChatMessage = {
id: 'wake:assistant',
@ -289,7 +423,7 @@ describe('AgentChatMessageList', () => {
expect(wrapper.find('[data-test-id="shared-reasoning-block"]').text()).toBe(
'Inspect the request. Then answer.',
);
expect(wrapper.find('[data-test-id="typing-indicator"]').exists()).toBe(false);
expect(wrapper.find('[data-test-id="agent-typing-indicator"]').exists()).toBe(false);
});
it('renders thinking below standalone assistant output', () => {

View File

@ -15,6 +15,8 @@ import {
type AgentConfigFingerprint,
} from '../composables/agentTelemetry.utils';
import type { AgentJsonConfig } from '../types';
import AgentChatPlan from '../components/AgentChatPlan.vue';
import { planMessage, planView } from './fixtures/agent-plan';
const sendMessageMock = vi.fn();
const stopGeneratingMock = vi.fn();
@ -666,6 +668,98 @@ describe('AgentChatPanel', () => {
},
);
describe('plan card', () => {
it('renders live plan results above the input without changing submission', async () => {
const wrapper = mountPanel();
expect(wrapper.findComponent(AgentChatPlan).exists()).toBe(false);
messagesMock.value = [planMessage(planView(), { state: 'running', output: undefined })];
await nextTick();
expect(wrapper.findComponent(AgentChatPlan).exists()).toBe(false);
messagesMock.value[0].toolCalls![0].state = 'done';
messagesMock.value[0].toolCalls![0].output = planView();
await nextTick();
const card = wrapper.getComponent(AgentChatPlan);
expect(card.props('plan').revision).toBe(1);
expect(wrapper.get('[data-testid="chat-input"]').findComponent(AgentChatPlan).exists()).toBe(
true,
);
const chatInput = wrapper.getComponent({ name: 'ChatInputBase' });
chatInput.vm.$emit('update:modelValue', 'Continue the research');
await nextTick();
await wrapper.get('form').trigger('submit');
expect(sendMessageMock).toHaveBeenCalled();
wrapper.unmount();
});
it('restores history, preserves expansion across updates, and clears with the conversation', async () => {
messagesMock.value = [planMessage(planView())];
const wrapper = mountPanel({ continueSessionId: 't1' });
await wrapper.get('[data-testid="agent-chat-plan"] button').trigger('click');
messagesMock.value.push(planMessage(planView({ revision: 2 }), { tool: 'update_plan' }));
await nextTick();
expect(wrapper.getComponent(AgentChatPlan).props('plan').revision).toBe(2);
expect(
wrapper.get('[data-testid="agent-chat-plan"] button').attributes('aria-expanded'),
).toBe('true');
messagesMock.value.push(planMessage({ error: 'conflict' }, { tool: 'update_plan' }));
await nextTick();
expect(wrapper.getComponent(AgentChatPlan).props('plan').revision).toBe(2);
await wrapper.setProps({ continueSessionId: 't2' });
expect(
wrapper.get('[data-testid="agent-chat-plan"] button').attributes('aria-expanded'),
).toBe('false');
messagesMock.value = [];
await nextTick();
expect(wrapper.findComponent(AgentChatPlan).exists()).toBe(false);
wrapper.unmount();
});
it('keeps background jobs visible and independent of the plan', async () => {
messagesMock.value = [planMessage(planView({ closed: true }))];
backgroundJobsMock.value = [
{
id: 'job-1',
kind: 'subagent',
title: 'Separate work',
status: 'running',
startedAt: '2026-09-30T10:00:00Z',
},
];
const wrapper = mountPanel({ backgroundJobsActive: true, continueSessionId: 't1' });
const card = wrapper.getComponent(AgentChatPlan);
const job = wrapper.get('[data-testid="agent-background-jobs"]');
expect(
card.element.compareDocumentPosition(job.element) & Node.DOCUMENT_POSITION_PRECEDING,
).toBeTruthy();
await job.get('button').trigger('click');
expect(card.get('button').attributes('aria-expanded')).toBe('false');
expect(job.text()).toContain('Separate work');
messagesMock.value.push(planMessage(null, { tool: 'read_plan' }));
await nextTick();
expect(wrapper.findComponent(AgentChatPlan).exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-background-jobs"]').exists()).toBe(true);
wrapper.unmount();
});
it('links a plan to the current session trace only when the session is available', async () => {
messagesMock.value = [planMessage(planView())];
const wrapper = mountPanel();
await wrapper.get('[data-testid="agent-chat-plan"] button').trigger('click');
expect(wrapper.find('[data-testid="agent-chat-plan-trace"]').exists()).toBe(false);
await wrapper.setProps({ continueSessionId: 't1' });
await wrapper.get('[data-testid="agent-chat-plan"] button').trigger('click');
expect(wrapper.get('[data-testid="agent-chat-plan-trace"]').attributes('href')).toBe(
'/projects/p1/agents/a1/sessions/t1',
);
await wrapper.setProps({ continueSessionId: 't2' });
await wrapper.get('[data-testid="agent-chat-plan"] button').trigger('click');
expect(wrapper.get('[data-testid="agent-chat-plan-trace"]').attributes('href')).toBe(
'/projects/p1/agents/a1/sessions/t2',
);
wrapper.unmount();
});
});
describe('background task panel', () => {
afterEach(() => vi.useRealTimers());
const job: AgentBackgroundJobDto = {

View File

@ -0,0 +1,363 @@
import { N8nAiActivityStepGroup, N8nIcon } from '@n8n/design-system';
import userEvent from '@testing-library/user-event';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import AgentChatPlan from '../components/AgentChatPlan.vue';
import { planTask, planView } from './fixtures/agent-plan';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string | number> }) => {
if (key === 'agents.chat.plan.title') return `Plan: ${options?.interpolate?.title}`;
if (key === 'agents.chat.plan.progress')
return `${options?.interpolate?.done} of ${options?.interpolate?.total} tasks done`;
return key;
},
}),
}));
describe('AgentChatPlan timer', () => {
const startedAt = '2026-10-01T10:00:00.000Z';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(startedAt));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('starts only with stored timing and keeps counting while the parent reviews completed work', async () => {
const wrapper = mount(AgentChatPlan, { props: { plan: planView({ startedAt: null }) } });
try {
expect(wrapper.find('[data-testid="agent-chat-plan-timer"]').exists()).toBe(false);
await wrapper.setProps({ plan: planView({ startedAt, revision: 2 }) });
const timer = () => wrapper.get('[data-testid="agent-chat-plan-timer"]');
expect(timer().text()).toBe('0:00');
expect(timer().attributes('aria-live')).toBe('off');
await vi.advanceTimersByTimeAsync(31_000);
expect(timer().text()).toBe('0:31');
await wrapper.setProps({
plan: planView({
startedAt,
revision: 3,
document: { title: 'Plan', items: [planTask(1, 'done')] },
}),
});
await vi.advanceTimersByTimeAsync(5_000);
expect(timer().text()).toBe('0:36');
await wrapper.setProps({
plan: planView({
startedAt,
closed: true,
closedAt: '2026-10-01T10:00:35.000Z',
revision: 4,
}),
});
expect(timer().text()).toBe('0:35');
await vi.advanceTimersByTimeAsync(60_000);
expect(timer().text()).toBe('0:35');
} finally {
wrapper.unmount();
}
});
it('restores elapsed time on remount and rolls over into hours', async () => {
vi.setSystemTime(new Date('2026-10-01T10:59:59.000Z'));
const plan = planView({ startedAt });
const wrapper = mount(AgentChatPlan, { props: { plan } });
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('59:59');
await vi.advanceTimersByTimeAsync(1_000);
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('1:00:00');
wrapper.unmount();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5_000);
const restored = mount(AgentChatPlan, { props: { plan } });
try {
expect(restored.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('1:00:05');
const clearInterval = vi.spyOn(globalThis, 'clearInterval');
await restored.setProps({ plan: planView({ startedAt: null, planId: planTask(99).id }) });
expect(restored.find('[data-testid="agent-chat-plan-timer"]').exists()).toBe(false);
expect(clearInterval).toHaveBeenCalled();
} finally {
restored.unmount();
}
});
it('restores a closed plan with a fixed duration', async () => {
vi.setSystemTime(new Date('2026-10-02T10:00:00.000Z'));
const wrapper = mount(AgentChatPlan, {
props: {
plan: planView({
startedAt,
closed: true,
closedAt: '2026-10-01T11:02:03.000Z',
}),
},
});
try {
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('1:02:03');
await vi.advanceTimersByTimeAsync(10_000);
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('1:02:03');
expect(vi.getTimerCount()).toBe(0);
} finally {
wrapper.unmount();
}
});
it('pauses timer updates in a hidden tab and catches up when visible', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
const wrapper = mount(AgentChatPlan, { props: { plan: planView({ startedAt }) } });
try {
await nextTick();
visibility.mockReturnValue('hidden');
document.dispatchEvent(new Event('visibilitychange'));
await nextTick();
await vi.advanceTimersByTimeAsync(10_000);
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('0:00');
visibility.mockReturnValue('visible');
document.dispatchEvent(new Event('visibilitychange'));
await nextTick();
expect(wrapper.get('[data-testid="agent-chat-plan-timer"]').text()).toBe('0:10');
} finally {
wrapper.unmount();
}
});
it.each([planView(), planView({ startedAt, closed: true }), planView({ startedAt: 'invalid' })])(
'does not invent elapsed time for incomplete timing metadata',
(plan) => {
const wrapper = mount(AgentChatPlan, { props: { plan } });
expect(wrapper.find('[data-testid="agent-chat-plan-timer"]').exists()).toBe(false);
wrapper.unmount();
},
);
});
describe('AgentChatPlan', () => {
it('starts collapsed and supports keyboard expansion without changing focus', async () => {
const user = userEvent.setup();
const wrapper = mount(AgentChatPlan, { props: { plan: planView() }, attachTo: document.body });
try {
const trigger = wrapper.get('button');
expect(trigger.attributes('aria-expanded')).toBe('false');
expect(trigger.text()).toContain('Plan: Compare support platforms');
expect(wrapper.getComponent(N8nAiActivityStepGroup).props('contentPosition')).toBe('below');
expect(trigger.text()).not.toContain('1 of 3 tasks done');
trigger.element.focus();
await user.keyboard('{Enter}');
expect(trigger.attributes('aria-expanded')).toBe('true');
expect(wrapper.get('[data-testid="agent-chat-plan-summary"]').text()).toBe(
'1 of 3 tasks done',
);
expect(wrapper.get('[data-testid="agent-chat-plan-items"]').text()).toContain('Research');
expect(wrapper.get('[data-testid="agent-chat-plan-details"]').attributes('tabindex')).toBe(
'0',
);
expect(wrapper.get('[data-testid="agent-chat-plan-details"]').attributes('aria-label')).toBe(
'Compare support platforms',
);
expect(wrapper.findAll('[data-status]').map((row) => row.attributes('aria-label'))).toEqual([
'agents.chat.plan.status.pending',
'agents.chat.plan.status.inProgress',
'agents.chat.plan.status.done',
'agents.chat.plan.status.pending',
]);
await wrapper.setProps({ plan: planView({ revision: 2, closed: true }) });
expect(trigger.text()).toContain('Compare support platforms');
expect(wrapper.text()).not.toContain('Closed');
expect(trigger.attributes('aria-expanded')).toBe('true');
await user.keyboard(' ');
expect(trigger.attributes('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(trigger.element);
} finally {
wrapper.unmount();
}
});
it('shows Agent-written activity and details, then closes without implying success', async () => {
const plan = planView({
document: {
title: 'Research options',
presentation: { label: 'Checking sources', detail: 'Reviewed two of three sources.' },
items: [planTask(1, 'in_progress'), planTask(2, 'failed'), planTask(3)],
},
});
const wrapper = mount(AgentChatPlan, { props: { plan } });
const trigger = wrapper.get('button');
const indicator = () => wrapper.findAllComponents(N8nIcon)[0];
expect(trigger.text()).toContain('Checking sources');
expect(indicator().props()).toMatchObject({ icon: 'loader-circle', spin: true });
await trigger.trigger('click');
expect(wrapper.get('[data-testid="agent-chat-plan-details"]').attributes('aria-label')).toBe(
'Research options',
);
expect(wrapper.get('p').text()).toBe('Reviewed two of three sources.');
await wrapper.setProps({
plan: {
...plan,
revision: 2,
document: {
...plan.document,
presentation: { label: 'Stopped checking', detail: 'One source remains unchecked.' },
},
},
});
expect(trigger.text()).toContain('Stopped checking');
await wrapper.setProps({ plan: { ...wrapper.props('plan'), revision: 3, closed: true } });
expect(trigger.text()).toContain('One source remains unchecked.');
expect(wrapper.getComponent(N8nAiActivityStepGroup).attributes('title')).toBe(
'One source remains unchecked.',
);
expect(wrapper.text()).not.toContain('Plan closed');
expect(trigger.text()).not.toContain('Stopped checking');
expect(indicator().props()).toMatchObject({ icon: 'list-checks', spin: false });
expect(wrapper.text().match(/One source remains unchecked\./g)).toHaveLength(1);
expect(wrapper.findAll('[data-status]').map((row) => row.attributes('data-status'))).toEqual([
'in_progress',
'failed',
'pending',
]);
expect(wrapper.get('[data-testid="agent-chat-plan-summary"]').text()).toBe('0 of 3 tasks done');
expect(trigger.attributes('aria-expanded')).toBe('true');
wrapper.unmount();
});
it('uses the plan title when a closed plan has no final summary', () => {
const plan = planView({
closed: true,
document: { ...planView().document, presentation: { label: 'Checking sources' } },
});
const wrapper = mount(AgentChatPlan, { props: { plan } });
expect(wrapper.get('button').text()).toContain(plan.document.title);
expect(wrapper.text()).not.toContain('Checking sources');
expect(wrapper.text()).not.toContain('Plan closed');
wrapper.unmount();
});
it.each(['pending', 'in_progress', 'done', 'failed', 'cancelled'] as const)(
'shows a header spinner only for In progress work, not %s text',
async (status) => {
const wrapper = mount(AgentChatPlan, {
props: {
plan: planView({
document: {
title: 'Plan',
presentation: { label: 'Working' },
items: [{ ...planTask(10), kind: 'group', tasks: [planTask(1, status)] }],
},
}),
},
});
expect(wrapper.findAllComponents(N8nIcon)[0].props('spin')).toBe(status === 'in_progress');
wrapper.unmount();
},
);
it('shows compact titles and an Agent-written note without task descriptions or results', async () => {
const detail = '<img src=x onerror=alert(1)>\nSecond line';
const wrapper = mount(AgentChatPlan, {
props: {
plan: planView({
document: {
title: 'Plan',
presentation: { label: '<b>Checking sources</b>', detail },
items: [
{ ...planTask(1), description: 'Check sources' },
{
...planTask(2, 'done'),
description: 'Hidden description',
resultSummary: 'Found two sources',
},
{ ...planTask(3), description: ' ', resultSummary: '\n' },
{
...planTask(10),
kind: 'group',
description: 'Hidden group description',
resultSummary: 'Group summary',
tasks: [{ ...planTask(4), description: 'Child description', resultSummary: ' ' }],
},
],
},
}),
},
});
await wrapper.get('button').trigger('click');
expect(wrapper.find('img').exists()).toBe(false);
expect(wrapper.find('b').exists()).toBe(false);
expect(wrapper.text()).toContain(detail);
expect(wrapper.text()).not.toContain('Check sources');
expect(wrapper.text()).not.toContain('Found two sources');
expect(wrapper.text()).not.toContain('Group summary');
expect(wrapper.text()).not.toContain('Child description');
expect(wrapper.text()).not.toContain('Hidden');
expect(wrapper.findAll('li')[2].find('p').exists()).toBe(false);
expect(wrapper.findAll('[data-status] + span').map((row) => row.text())).toEqual([
'Task 1',
'Task 2',
'Task 3',
'Task 10',
'Task 4',
]);
wrapper.unmount();
});
it('renders each status distinctly without inferring group completion', async () => {
const statuses = ['pending', 'in_progress', 'done', 'failed', 'cancelled'] as const;
const plan = planView({
document: {
title: 'Statuses',
items: [
{
...planTask(10, 'done'),
kind: 'group',
tasks: statuses.map((status, index) => planTask(index + 1, status)),
},
],
},
});
const wrapper = mount(AgentChatPlan, { props: { plan } });
await wrapper.get('button').trigger('click');
expect(
wrapper.findAll('[data-status]').map((row) => row.getComponent(N8nIcon).props('icon')),
).toEqual(['check', 'circle', 'loader-circle', 'check', 'x', 'x']);
expect(wrapper.findAll('[data-status]').map((row) => row.attributes('data-status'))).toEqual([
'done',
...statuses,
]);
expect(wrapper.get('[data-testid="agent-chat-plan-summary"]').text()).toBe('1 of 5 tasks done');
expect(
wrapper.findAll('[data-status]').map((row) => row.getComponent(N8nIcon).props('spin')),
).toEqual([false, false, true, false, false, false]);
expect(wrapper.find('[class*="shimmer"]').exists()).toBe(false);
wrapper.unmount();
});
it('preserves document order and treats long titles as plain text', async () => {
const title = '<img src=x onerror=alert(1)>'.repeat(20);
const plan = planView({ document: { title, items: [{ ...planTask(1), title }, planTask(2)] } });
const wrapper = mount(AgentChatPlan, { props: { plan } });
await wrapper.get('button').trigger('click');
expect(wrapper.find('img').exists()).toBe(false);
expect(wrapper.findAll('li').map((row) => row.text())).toEqual([title, 'Task 2']);
expect(wrapper.getComponent(N8nAiActivityStepGroup).attributes('title')).toBe(title);
wrapper.unmount();
});
it('shows empty plans and resets expansion for a new plan', async () => {
const wrapper = mount(AgentChatPlan, { props: { plan: planView() } });
await wrapper.get('button').trigger('click');
await wrapper.setProps({
plan: planView({
planId: '22222222-2222-4222-8222-222222222222',
document: { title: 'New plan', items: [] },
}),
});
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false');
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('0 of 0 tasks done');
expect(wrapper.findAll('li')).toHaveLength(0);
wrapper.unmount();
});
});

View File

@ -25,6 +25,7 @@ vi.mock('@n8n/design-system', () => ({
<span> · </span>
<span data-testid="tool-step-summary">{{ part }}</span>
</template>
<span v-if="error" data-testid="tool-step-warning" :title="error">⚠</span>
<div v-if="error && !hideErrorCallout" data-test-id="tool-step-error">{{ error }}</div>
<div v-if="isOpen"><slot /></div>
</div>
@ -59,6 +60,8 @@ vi.mock('@n8n/design-system', () => ({
vi.mock('@n8n/i18n', () => {
const i18n = {
translations: {
'agents.chat.plan.error.rejected':
'The plan change was rejected. Expand this step for details.',
'agents.chat.toolNames.webSearch': 'Web search',
'agents.chat.toolNames.flagMemory': 'Memory noted',
'instanceAi.tools.search_nodes': 'Search nodes',
@ -112,6 +115,125 @@ function mountSteps(
}
describe('AgentChatToolSteps', () => {
it.each([
{
tool: 'update_plan',
state: TOOL_CALL_STATE.ERROR,
output: {
error:
'AI_InvalidToolInputError: Invalid input for tool update_plan: Invalid uuid in dependsOn',
},
},
{
tool: 'create_plan',
state: TOOL_CALL_STATE.ERROR,
output: 'Error: AI_InvalidToolInputError: Invalid input for tool create_plan: Invalid uuid',
},
{
tool: 'update_plan',
state: TOOL_CALL_STATE.DONE,
output: { error: 'invalid_plan', message: 'New items must be Pending' },
},
{
tool: 'update_plan',
state: TOOL_CALL_STATE.ERROR,
output: { error: 'invalid_plan', message: 'Missing dependency' },
},
{
tool: 'close_plan',
state: TOOL_CALL_STATE.DONE,
output: { error: 'conflict', message: 'Read the current plan before another write' },
},
{
tool: 'update_plan',
state: TOOL_CALL_STATE.ERROR,
output: { error: 'conflict', message: 'Read the current plan before another write' },
},
])('keeps a recoverable $tool error compact in state $state', async (call) => {
for (const canFixWithAssistant of [false, true]) {
const toolCall = { ...call, toolCallId: 'tc-plan', input: { expectedRevision: 4 } };
const wrapper = mountSteps([toolCall], { canFixWithAssistant, executionId: 'exec-1' });
expect(wrapper.get('[data-testid="tool-step-warning"]').attributes('title')).toBe(
'The plan change was rejected. Expand this step for details.',
);
expect(wrapper.find('[data-test-id="tool-step-error"]').exists()).toBe(false);
expect(wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(
false,
);
expect(wrapper.find('pre').exists()).toBe(false);
await wrapper.get('button').trigger('click');
const details = wrapper.findAll('pre');
expect(details[0].text()).toBe(JSON.stringify(toolCall.input, null, 2));
expect(details[1].text()).toBe(
typeof call.output === 'string' ? call.output : JSON.stringify(call.output, null, 2),
);
expect(wrapper.find('[data-test-id="tool-step-error"]').exists()).toBe(false);
wrapper.unmount();
}
});
it.each([
['update_plan', { error: 'Database connection failed' }],
['update_plan', { error: 'unavailable', message: 'Missing thread context' }],
['update_plan', 'Unexpected failure containing AI_InvalidToolInputError'],
['update_plan', 'AI_InvalidToolInputError: Invalid input for tool another_tool: Invalid uuid'],
['search_nodes', 'AI_InvalidToolInputError: Invalid input for tool search_nodes: Invalid name'],
['search_nodes', { error: 'conflict' }],
])('keeps existing failure handling for %s with %j', (tool, output) => {
const call: ToolCall = { tool, output, state: TOOL_CALL_STATE.ERROR, toolCallId: 'tc-error' };
const withoutFix = mountSteps([call]);
expect(withoutFix.find('[data-test-id="tool-step-error"]').exists()).toBe(true);
const withFix = mountSteps([call], { canFixWithAssistant: true, executionId: 'exec-1' });
expect(withFix.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(true);
});
it('keeps a rejected call visible after a successful retry without adding it to fixable errors', async () => {
const rejected: ToolCall = {
tool: 'update_plan',
toolCallId: 'tc-rejected',
state: TOOL_CALL_STATE.ERROR,
output: { error: 'invalid_plan', message: 'Missing dependency' },
};
const wrapper = mountSteps([rejected], {
canFixWithAssistant: true,
executionId: 'exec-1',
});
await wrapper.setProps({
toolCalls: [
rejected,
{
tool: 'update_plan',
toolCallId: 'tc-retry',
state: TOOL_CALL_STATE.DONE,
output: { revision: 5 },
},
],
});
expect(wrapper.findAll('[data-testid="tool-step-warning"]')).toHaveLength(1);
expect(wrapper.find('[data-test-id="tool-step-error"]').exists()).toBe(false);
expect(wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(
false,
);
await wrapper.setProps({
toolCalls: [
rejected,
{
tool: 'search_nodes',
toolCallId: 'tc-failed',
state: TOOL_CALL_STATE.ERROR,
output: 'Connection failed',
},
],
});
const callout = wrapper.get('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]');
expect(callout.text()).toContain('Connection failed');
expect(callout.text()).not.toContain('Missing dependency');
await wrapper.get('[data-test-id="agent-chat-tool-fix-with-assistant"]').trigger('click');
expect(wrapper.emitted('fixWithAssistant')).toEqual([
[[expect.objectContaining({ toolCallId: 'tc-failed' })]],
]);
});
it.each([
[TOOL_CALL_STATE.PENDING, 'true'],
[TOOL_CALL_STATE.RUNNING, 'true'],

View File

@ -0,0 +1,387 @@
import { describe, expect, it } from 'vitest';
import { convertDbMessages } from '@/features/ai/shared/agentsChat/messageMappers';
import { buildAgentPlanDisplayGroups, selectLatestAgentPlan } from '../utils/agent-plan';
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
import { planMessage, planTask, planView } from './fixtures/agent-plan';
describe('buildAgentPlanDisplayGroups', () => {
const initialCallId = expect.stringMatching(/^plan-call-\d+$/);
const initial = {
...planView(),
document: {
title: 'Compare platforms',
description: 'Choose a support platform',
items: [
{ ...planTask(1), description: 'Find candidates', dependsOn: [] },
{ ...planTask(2), description: 'Compare candidates', dependsOn: [planTask(1).id] },
],
},
};
const update = (document: unknown, overrides: Partial<ToolCall> = {}) =>
planMessage(
{ ...initial, revision: 2, document },
{ tool: 'update_plan', toolCallId: 'update', ...overrides },
);
function visibleCalls(messages: ChatMessage[]) {
return buildAgentPlanDisplayGroups(messages).flatMap((group) =>
group.kind === 'toolRun'
? group.toolCalls.map((call) => call.toolCallId)
: group.kind === 'message'
? (group.message.toolCalls ?? []).map((call) => call.toolCallId)
: [],
);
}
const progress = {
...initial.document,
presentation: { label: 'Compare candidates', detail: 'Found three candidates' },
items: initial.document.items.map((item) => ({
...item,
title: `Completed ${item.title}`,
status: 'done',
resultSummary: 'Found candidates',
startedAt: '2026-10-01T10:00:00Z',
endedAt: '2026-10-01T10:01:00Z',
})),
};
it('hides progress without changing the stored messages or current card', () => {
const messages = [planMessage(initial), update(progress)];
const before = structuredClone(messages);
expect(visibleCalls(messages)).toEqual([initialCallId]);
expect(messages).toEqual(before);
expect(selectLatestAgentPlan(messages)?.revision).toBe(2);
});
it('filters each plan call independently across messages', () => {
const current = { ...initial, revision: 2, document: progress };
const created = planMessage(initial);
const updated = planMessage(current, { tool: 'update_plan' });
const read = planMessage(current, { tool: 'read_plan' });
const messages = [created, updated, read];
expect(new Set(messages.map((message) => message.id)).size).toBe(3);
expect(visibleCalls(messages)).toEqual([
created.toolCalls![0].toolCallId,
read.toolCalls![0].toolCallId,
]);
});
it.each([
['plan title', { ...progress, title: 'New goal' }],
['plan description', { ...progress, description: 'New requirements' }],
['added task', { ...progress, items: [...progress.items, planTask(3)] }],
['removed task', { ...progress, items: progress.items.slice(1) }],
['task order', { ...progress, items: [...progress.items].reverse() }],
[
'task description',
{
...progress,
items: [{ ...progress.items[0], description: 'New scope' }, progress.items[1]],
},
],
[
'dependencies',
{ ...progress, items: [progress.items[0], { ...progress.items[1], dependsOn: [] }] },
],
[
'fallback',
{
...progress,
items: [progress.items[0], { ...progress.items[1], fallbackFor: planTask(1).id }],
},
],
['unknown document field', { ...progress, futureRule: true }],
[
'unknown task field',
{ ...progress, items: [{ ...progress.items[0], futureRule: true }, progress.items[1]] },
],
])('keeps a change to %s visible', (_name, document) => {
expect(visibleCalls([planMessage(initial), update(document)])).toEqual([
initialCallId,
'update',
]);
});
it('ignores group progress but keeps group membership and order changes visible', () => {
const group = { ...planTask(10), kind: 'group' as const, tasks: initial.document.items };
const document = { ...initial.document, items: [group, planTask(3)] };
const previous = planMessage({ ...initial, document });
const changedGroup = {
...group,
title: 'Compared',
status: 'done',
resultSummary: 'Done',
tasks: progress.items,
};
expect(
visibleCalls([previous, update({ ...document, items: [changedGroup, planTask(3)] })]),
).toEqual([initialCallId]);
for (const items of [
[planTask(3), changedGroup],
[{ ...changedGroup, tasks: progress.items.slice(1) }, progress.items[0], planTask(3)],
[{ ...changedGroup, tasks: [...progress.items].reverse() }, planTask(3)],
]) {
expect(visibleCalls([previous, update({ ...document, items })])).toEqual([
initialCallId,
'update',
]);
}
});
it.each(['pending', 'running', 'done'] as const)(
'hides a known progress-only %s call before its result arrives',
(state) => {
const call = update(progress, {
state,
output: undefined,
input: { planId: initial.planId, expectedRevision: 1, document: progress },
});
expect(visibleCalls([planMessage(initial), call])).toEqual([initialCallId]);
},
);
it.each([
['progress', { ...initial, revision: 2, document: progress }, false],
[
'scope change',
{ ...initial, revision: 2, document: { ...progress, title: 'New goal' } },
true,
],
['conflict', { error: 'conflict' }, true],
['invalid plan', { error: 'invalid_plan' }, true],
['null', null, true],
['malformed', 'malformed', true],
])('uses the %s result after completion without output', (_name, output, visible) => {
const previous = planMessage(initial);
const message = update(progress, {
state: 'running',
output: undefined,
input: { planId: initial.planId, expectedRevision: 1, document: progress },
});
const messages = [previous, message];
const call = message.toolCalls![0];
expect(visibleCalls(messages)).toEqual([initialCallId]);
call.state = 'done';
expect(visibleCalls(messages)).toEqual([initialCallId]);
expect(selectLatestAgentPlan(messages)?.revision).toBe(1);
call.output = output;
expect(visibleCalls(messages)).toEqual(visible ? [initialCallId, 'update'] : [initialCallId]);
});
it.each([
{ state: 'error' as const },
{ state: 'suspended' as const },
{ state: 'cancelled' as const },
{ canceled: true },
{ output: { error: 'conflict', message: 'Read the current plan' } },
{ output: { error: 'invalid_plan' } },
{ output: 'malformed' },
{
state: 'running' as const,
input: { planId: initial.planId, expectedRevision: 99, document: progress },
},
])('keeps errors and uncertain calls visible: %j', (overrides) => {
expect(visibleCalls([planMessage(initial), update(progress, overrides)])).toEqual([
initialCallId,
'update',
]);
});
it('requires the preceding revision of the same plan', () => {
expect(visibleCalls([update(progress)])).toEqual(['update']);
expect(visibleCalls([planMessage({ ...initial, revision: 4 }), update(progress)])).toEqual([
initialCallId,
'update',
]);
expect(
visibleCalls([planMessage({ ...initial, planId: planTask(99).id }), update(progress)]),
).toEqual([initialCallId, 'update']);
});
it('keeps create, read, and close calls visible', () => {
for (const tool of ['create_plan', 'read_plan', 'close_plan']) {
expect(visibleCalls([planMessage(initial), update(progress, { tool })])).toEqual([
initialCallId,
'update',
]);
}
});
it('restores filtering from persisted results without tool inputs', () => {
const history = convertDbMessages([
{
id: 'assistant',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'create',
toolName: 'create_plan',
state: 'resolved',
output: initial,
},
{
type: 'tool-call',
toolCallId: 'update',
toolName: 'update_plan',
state: 'resolved',
output: { ...initial, revision: 2, document: progress },
},
],
},
]);
expect(visibleCalls(history)).toEqual(['create']);
});
});
describe('selectLatestAgentPlan', () => {
it('returns no card without a successful plan result', () => {
expect(selectLatestAgentPlan([])).toBeNull();
expect(
selectLatestAgentPlan([{ id: 'user', role: 'user', content: 'Create a plan' }]),
).toBeNull();
expect(selectLatestAgentPlan([planMessage(planView(), { tool: 'write_todos' })])).toBeNull();
});
it.each(['create_plan', 'read_plan', 'update_plan', 'close_plan'])(
'reads a valid %s result',
(tool) => {
const plan = planView({ closed: tool === 'close_plan' });
expect(selectLatestAgentPlan([planMessage(plan, { tool })])).toEqual(plan);
},
);
it('uses revisions instead of response order within one plan', () => {
const initial = planView();
const latest = planView({ revision: 3, closed: true });
expect(
selectLatestAgentPlan([
planMessage(initial),
planMessage(latest, { tool: 'close_plan' }),
planMessage(planView({ revision: 2 }), { tool: 'read_plan' }),
]),
).toEqual(latest);
});
it('selects a later plan even when its revision is lower', () => {
const next = planView({ planId: '22222222-2222-4222-8222-222222222222' });
expect(
selectLatestAgentPlan([
planMessage(planView({ revision: 8, closed: true })),
planMessage(next),
]),
).toEqual(next);
});
it.each(['pending', 'running', 'suspended', 'error', 'cancelled'] as const)(
'keeps the last accepted state during a %s call',
(state) => {
const initial = planView();
expect(
selectLatestAgentPlan([
planMessage(initial),
planMessage(planView({ revision: 2 }), { tool: 'update_plan', state }),
]),
).toEqual(initial);
},
);
it.each([
undefined,
'not a plan',
{ error: 'conflict', message: 'Read the plan' },
{ ...planView(), revision: 0 },
{ ...planView(), planId: 'invalid' },
{ ...planView(), closed: 'true' },
{
...planView(),
document: { ...planView().document, presentation: { label: ' ' } },
},
{ ...planView(), document: { title: 'Plan', items: [{ ...planTask(1), status: 'unknown' }] } },
{
...planView(),
document: {
title: 'Plan',
items: [
{ ...planTask(1), kind: 'group', tasks: [{ ...planTask(2), kind: 'group', tasks: [] }] },
],
},
},
])('ignores malformed and error results: %j', (output) => {
const current = planView();
expect(
selectLatestAgentPlan([planMessage(current), planMessage(output, { tool: 'update_plan' })]),
).toEqual(current);
});
it('ignores canceled results even when their state is done', () => {
expect(selectLatestAgentPlan([planMessage(planView(), { canceled: true })])).toBeNull();
});
it('clears the card only for a successful null read and permits a later plan', () => {
const previous = planMessage(planView({ closed: true }));
const empty = planMessage(null, { tool: 'read_plan' });
expect(selectLatestAgentPlan([previous, empty])).toBeNull();
expect(selectLatestAgentPlan([previous, planMessage(null, { tool: 'update_plan' })])).toEqual(
planView({ closed: true }),
);
expect(
selectLatestAgentPlan([previous, planMessage(null, { tool: 'read_plan', state: 'error' })]),
).toEqual(planView({ closed: true }));
expect(selectLatestAgentPlan([previous, empty, planMessage(planView())])).toEqual(planView());
});
it('accepts display fields without duplicating graph validation', () => {
const view = planView({ document: { title: 'Empty plan', items: [] } });
expect(
selectLatestAgentPlan([
planMessage({
...view,
readiness: { ready: [], blocked: [] },
document: { ...view.document, description: 'Goal' },
}),
]),
).toEqual(view);
});
it('restores the same card from persisted tool results', () => {
const latest = planView({
revision: 2,
startedAt: '2026-10-01T10:00:00.000Z',
closedAt: null,
document: {
...planView().document,
presentation: { label: 'Reviewing sources', detail: 'Checked two of three sources.' },
items: [
{ ...planTask(1, 'done'), description: 'Research', resultSummary: 'Found sources' },
],
},
});
const history = convertDbMessages([
{
id: 'assistant',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'create',
toolName: 'create_plan',
state: 'resolved',
output: planView(),
},
{
type: 'tool-call',
toolCallId: 'update',
toolName: 'update_plan',
state: 'resolved',
output: latest,
},
],
},
]);
expect(selectLatestAgentPlan(history)).toEqual(latest);
expect(selectLatestAgentPlan([])).toBeNull();
});
});

View File

@ -0,0 +1,46 @@
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
import type { AgentPlanItemStatus, AgentPlanView } from '../../utils/agent-plan';
export function planTask(index: number, status: AgentPlanItemStatus = 'pending') {
return {
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
kind: 'task' as const,
title: `Task ${index}`,
status,
};
}
export function planView(overrides: Partial<AgentPlanView> = {}): AgentPlanView {
return {
planId: '11111111-1111-4111-8111-111111111111',
revision: 1,
closed: false,
document: {
title: 'Compare support platforms',
items: [
planTask(1),
{
...planTask(10, 'in_progress'),
kind: 'group',
title: 'Research',
tasks: [planTask(2, 'done'), planTask(3)],
},
],
},
...overrides,
};
}
let nextMessageId = 0;
export function planMessage(output: unknown, overrides: Partial<ToolCall> = {}): ChatMessage {
const id = ++nextMessageId;
return {
id: `assistant-${id}`,
role: 'assistant',
content: '',
toolCalls: [
{ tool: 'create_plan', toolCallId: `plan-call-${id}`, state: 'done', output, ...overrides },
],
};
}

View File

@ -213,6 +213,15 @@ skill, What the skill does, and Allowed tools. Use `Save skill` to confirm.
- Confirm UI text uses i18n.
- Confirm the layout works at 375 by 667 pixels in light and dark themes.
## Recoverable plan errors
Show rejected plan input and revision conflicts in the normal tool-call row.
Keep the warning icon and use a short tooltip. Do not show a separate error
callout or a Fix with Assistant action for these errors. Keep the full input
and output in the collapsed details and the trace. Keep earlier failed calls
visible after a successful retry. Unexpected failures keep the existing error
treatment.
## Extend this document
Add a section when an Agent-specific pattern applies to two or more Agent

View File

@ -6,11 +6,7 @@ import { isAwaitingCard } from '@/features/ai/shared/agentsChat/n8nChatInteracti
import { useI18n } from '@n8n/i18n';
import { useSessionStorage } from '@vueuse/core';
import { TIME } from '@/app/constants/durations';
import {
buildDisplayGroups,
isAssistantGroup,
type DisplayGroup,
} from '@/features/ai/shared/agentsChat/displayGroups';
import { isAssistantGroup, type DisplayGroup } from '@/features/ai/shared/agentsChat/displayGroups';
import { getMessageInteractives, isRecord } from '@/features/ai/shared/agentsChat/messageMappers';
import {
getMessageThinkingSegments,
@ -34,6 +30,7 @@ import AgentBudgetNoticeCard from './AgentBudgetNoticeCard.vue';
import InteractiveCard from './interactive/InteractiveCard.vue';
import type { AgentFixWithAssistantFailure, AgentSendToAssistantEvent } from '../types';
import { looksLikeAgentChangeRequest } from '../utils/agent-change-request';
import { buildAgentPlanDisplayGroups } from '../utils/agent-plan';
import { isSameLocalDay, useChatDividerTimestamp } from '../utils/relative-time';
import { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE } from '../constants';
@ -152,7 +149,12 @@ function getMessageRenderItems(message: ChatMessage): MessageRenderItem[] {
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef');
const displayGroups = computed(() => buildDisplayGroups(props.messages));
const displayGroups = computed(() => buildAgentPlanDisplayGroups(props.messages));
const streamingGroupId = computed(() =>
props.messages.at(-1)?.status === CHAT_MESSAGE_STATUS.STREAMING
? displayGroups.value.at(-1)?.id
: undefined,
);
const formatChatDividerTimestamp = useChatDividerTimestamp();
@ -545,8 +547,8 @@ watch(
</div>
<AgentTypingIndicator
v-if="
group.finalMessage?.status === CHAT_MESSAGE_STATUS.STREAMING &&
!group.finalMessage.content &&
group.id === streamingGroupId &&
!group.finalMessage?.content &&
!group.toolCalls.length &&
!group.thinkingSegments.length
"

View File

@ -47,6 +47,9 @@ import {
} from '@/features/ai/shared/agentsChat/messageMappers';
import AgentChatEmptyState from './AgentChatEmptyState.vue';
import AgentChatMessageList from './AgentChatMessageList.vue';
import AgentChatPlan from './AgentChatPlan.vue';
import { selectLatestAgentPlan } from '../utils/agent-plan';
import { formatAgentElapsedTime } from '../utils/agent-elapsed-time';
import type {
AgentContinueLoadedEvent,
AgentSendToAssistantEvent,
@ -160,6 +163,8 @@ const {
budgetCards: props.budgetCards,
});
const currentPlan = computed(() => selectLatestAgentPlan(messages.value));
const queueEdit = ref<{
item: AgentChatQueueItem;
text: string;
@ -511,12 +516,7 @@ const backgroundElapsed = computed(() => {
const end = backgroundInProgress.value
? now.value
: Math.max(...backgroundJobs.value.map((job) => Date.parse(job.settledAt ?? '') || now.value));
const seconds = Number.isFinite(start) ? Math.max(0, Math.floor((end - start) / TIME.SECOND)) : 0;
const minutes = Math.floor(seconds / 60);
const remainder = String(seconds % 60).padStart(2, '0');
return minutes < 60
? `${minutes}:${remainder}`
: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${remainder}`;
return formatAgentElapsedTime(end - start);
});
const attachedFiles = ref<File[]>([]);
@ -1143,8 +1143,19 @@ onBeforeUnmount(() => {
@stop="stopGenerating"
@files-selected="handleFilesSelected"
>
<template v-if="displayedQueueRows.length" #header>
<div ref="messageQueue" :class="$style.messageQueue" data-testid="agent-message-queue">
<template v-if="currentPlan || displayedQueueRows.length" #header>
<AgentChatPlan
v-if="currentPlan"
:key="`${agentId}:${continueSessionId ?? ''}:${currentPlan.planId}`"
:plan="currentPlan"
:trace-route="continueSessionId ? backgroundTraceRoute : undefined"
/>
<div
v-if="displayedQueueRows.length"
ref="messageQueue"
:class="$style.messageQueue"
data-testid="agent-message-queue"
>
<Draggable
:id="queueListId"
:model-value="visibleQueueRows"
@ -1406,6 +1417,7 @@ onBeforeUnmount(() => {
}
.backgroundJobs {
display: none;
background: var(--background--surface);
box-shadow: var(--shadow--outline), var(--shadow--xs);
border-radius: var(--radius--xs);

View File

@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useDocumentVisibility, useIntervalFn } from '@vueuse/core';
import type { RouteLocationRaw } from 'vue-router';
import { N8nAiActivityStepGroup, N8nIcon, N8nLink } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import type { AgentPlanItemStatus, AgentPlanView } from '../utils/agent-plan';
import { formatAgentElapsedTime } from '../utils/agent-elapsed-time';
import { TIME } from '@/app/constants/durations';
const props = defineProps<{ plan: AgentPlanView; traceRoute?: RouteLocationRaw }>();
const i18n = useI18n();
const now = ref(Date.now());
const documentVisibility = useDocumentVisibility();
const startTime = computed(() => Date.parse(props.plan.startedAt ?? ''));
const { pause, resume } = useIntervalFn(
() => {
now.value = Date.now();
},
TIME.SECOND,
{ immediate: false },
);
watch(
() =>
Number.isFinite(startTime.value) &&
!props.plan.closed &&
documentVisibility.value === 'visible',
(active) => {
if (active) {
now.value = Date.now();
resume();
} else pause();
},
{ immediate: true },
);
const elapsed = computed(() => {
const endTime = props.plan.closed ? Date.parse(props.plan.closedAt ?? '') : now.value;
if (!Number.isFinite(startTime.value) || !Number.isFinite(endTime)) return null;
return formatAgentElapsedTime(endTime - startTime.value);
});
const label = computed(() =>
props.plan.closed
? (props.plan.document.presentation?.detail ?? props.plan.document.title)
: (props.plan.document.presentation?.label ??
i18n.baseText('agents.chat.plan.title', {
interpolate: { title: props.plan.document.title },
})),
);
const isRunning = computed(
() =>
!props.plan.closed &&
props.plan.document.items.some(
(item) =>
item.status === 'in_progress' ||
(item.kind === 'group' && item.tasks.some((task) => task.status === 'in_progress')),
),
);
const statusLabels: Record<AgentPlanItemStatus, BaseTextKey> = {
pending: 'agents.chat.plan.status.pending',
in_progress: 'agents.chat.plan.status.inProgress',
done: 'agents.chat.plan.status.done',
failed: 'agents.chat.plan.status.failed',
cancelled: 'agents.chat.plan.status.canceled',
};
const statusIcons = {
pending: 'circle',
in_progress: 'loader-circle',
done: 'check',
failed: 'x',
cancelled: 'x',
} as const;
const summary = computed(() => {
const tasks = props.plan.document.items.flatMap((item) =>
item.kind === 'group' ? item.tasks : [item],
);
return i18n.baseText('agents.chat.plan.progress', {
adjustToNumber: tasks.length,
interpolate: {
done: tasks.filter((task) => task.status === 'done').length,
total: tasks.length,
},
});
});
</script>
<template>
<div :class="$style.plan" data-testid="agent-chat-plan">
<N8nAiActivityStepGroup
:key="plan.planId"
:label="label"
:title="plan.closed || plan.document.presentation ? label : plan.document.title"
full-width
content-position="below"
>
<template #prefix>
<N8nIcon
:icon="isRunning ? 'loader-circle' : 'list-checks'"
:spin="isRunning"
:class="{ [$style.running]: isRunning }"
size="small"
aria-hidden="true"
data-testid="agent-chat-plan-indicator"
/>
</template>
<template v-if="elapsed !== null" #header-trailing>
<span :class="$style.timer" aria-live="off" data-testid="agent-chat-plan-timer">{{
elapsed
}}</span>
</template>
<div
:class="$style.details"
role="region"
:aria-label="plan.document.title"
tabindex="0"
data-testid="agent-chat-plan-details"
>
<ul
:class="$style.items"
:aria-label="plan.document.title"
data-testid="agent-chat-plan-items"
>
<li v-for="item in plan.document.items" :key="item.id">
<div :class="[$style.row, { [$style.group]: item.kind === 'group' }]">
<span
role="img"
:aria-label="i18n.baseText(statusLabels[item.status])"
:title="i18n.baseText(statusLabels[item.status])"
:data-status="item.status"
:class="$style.status"
>
<N8nIcon
:icon="statusIcons[item.status]"
:spin="item.status === 'in_progress'"
size="small"
aria-hidden="true"
/>
</span>
<span :class="$style.itemTitle" :title="item.title">{{ item.title }}</span>
</div>
<ul v-if="item.kind === 'group'" :class="$style.children">
<li v-for="task in item.tasks" :key="task.id" :class="$style.row">
<span
role="img"
:aria-label="i18n.baseText(statusLabels[task.status])"
:title="i18n.baseText(statusLabels[task.status])"
:data-status="task.status"
:class="$style.status"
>
<N8nIcon
:icon="statusIcons[task.status]"
:spin="task.status === 'in_progress'"
size="small"
aria-hidden="true"
/>
</span>
<span :class="$style.itemTitle" :title="task.title">{{ task.title }}</span>
</li>
</ul>
</li>
</ul>
</div>
<p
v-if="!plan.closed && plan.document.presentation?.detail"
:class="$style.detail"
:title="plan.document.presentation.detail"
>
{{ plan.document.presentation.detail }}
</p>
<div :class="$style.footer">
<N8nLink
v-if="traceRoute"
:to="traceRoute"
theme="text"
size="small"
underline
data-testid="agent-chat-plan-trace"
>
<span :class="$style.traceLabel">
<N8nIcon icon="arrow-right" size="small" aria-hidden="true" />
{{ i18n.baseText('agents.chat.plan.viewTrace') }}
</span>
</N8nLink>
<span :class="$style.summary" data-testid="agent-chat-plan-summary">{{ summary }}</span>
</div>
</N8nAiActivityStepGroup>
</div>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.plan {
margin: calc(-1 * var(--spacing--2xs)) calc(-1 * var(--spacing--2xs)) 0;
border-bottom: var(--border);
min-width: 0;
--ai-activity-step--height: auto;
--ai-activity-step--min-height: var(--height--xl);
--ai-activity-step--padding: var(--spacing--xs) var(--spacing--sm);
--ai-activity-step--color: var(--text-color);
}
.summary {
flex: 0 0 auto;
width: auto;
font-size: var(--font-size--2xs);
font-variant-numeric: tabular-nums;
color: var(--text-color--subtle);
margin-inline-start: auto;
}
.timer {
font-variant-numeric: tabular-nums;
font-size: var(--font-size--xs);
color: var(--text-color--subtler);
flex-shrink: 0;
}
.details {
padding: var(--spacing--2xs) var(--spacing--sm) 0;
max-height: 20vh;
overflow-y: auto;
overscroll-behavior: contain;
border-top: var(--border);
border-top-style: dashed;
overflow-wrap: anywhere;
}
.items {
list-style: none;
margin: 0;
padding: 0;
}
.detail {
margin: var(--spacing--2xs) 0 0;
padding-inline: var(--spacing--sm);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--regular);
color: var(--text-color--subtle);
white-space: pre-wrap;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.itemTitle {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.footer {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--sm);
}
.traceLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing--2xs);
}
.running {
color: var(--color--primary);
}
.children {
list-style: none;
margin: 0;
padding: 0 0 0 var(--spacing--lg);
}
.row {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
padding-block: var(--spacing--3xs);
font-size: var(--font-size--2xs);
line-height: var(--line-height--lg);
color: var(--text-color--subtle);
overflow-wrap: anywhere;
min-width: 0;
}
.group {
font-weight: var(--font-weight--bold);
}
.status {
display: inline-flex;
align-items: center;
height: calc(var(--font-size--2xs) * var(--line-height--lg));
flex-shrink: 0;
color: var(--color--foreground--shade-2);
--animation--spin--duration: var(--duration--slow);
&[data-status='in_progress'] {
color: var(--color--primary);
}
&[data-status='done'] {
color: var(--color--success);
@include motion.fade-in;
}
&[data-status='failed'] {
color: var(--color--danger);
}
}
</style>

View File

@ -20,6 +20,7 @@ import {
resolveSubAgentName,
} from '../utils/delegate-tool';
import { getToolCallDetails } from '../utils/tool-call-details';
import { isRecoverablePlanError } from '../utils/agent-plan';
import {
countIncompleteTodos,
isWriteTodosTool,
@ -55,6 +56,7 @@ const fixableFailures = computed<AgentFixWithAssistantFailure[]>(() => {
for (const toolCall of props.toolCalls) {
if (dismissed.has(toolCall.toolCallId)) continue;
if (toolCall.state !== TOOL_CALL_STATE.ERROR) continue;
if (isRecoverablePlanError(toolCall)) continue;
const error = toolStepError(toolCall)?.trim();
if (!error) continue;
@ -173,6 +175,7 @@ function toolStepView(tc: ToolCall): ToolStepDisplay {
}
function toolStepError(tc: ToolCall): string | undefined {
if (isRecoverablePlanError(tc)) return i18n.baseText('agents.chat.plan.error.rejected');
if (tc.state !== TOOL_CALL_STATE.ERROR) return undefined;
if (isEmptyToolErrorPayload(tc.output)) {
return i18n.baseText('agents.chat.toolError.generic');
@ -180,6 +183,10 @@ function toolStepError(tc: ToolCall): string | undefined {
return formatToolData(tc.output);
}
function hideToolErrorCallout(tc: ToolCall): boolean {
return isRecoverablePlanError(tc) || (showFix.value && tc.state === TOOL_CALL_STATE.ERROR);
}
function emitFixWithAssistant() {
if (fixableFailures.value.length === 0) return;
emit('fixWithAssistant', fixableFailures.value);
@ -218,7 +225,7 @@ function hasActiveToolCall(): boolean {
:label="view.label"
:loading="isToolStepLoading(tc)"
:error="toolStepError(tc)"
:hide-error-callout="showFix && tc.state === TOOL_CALL_STATE.ERROR"
:hide-error-callout="hideToolErrorCallout(tc)"
:has-content="view.expandable"
>
<div
@ -281,7 +288,7 @@ function hasActiveToolCall(): boolean {
:label="toolStepView(tc).label"
:loading="isToolStepLoading(tc)"
:error="toolStepError(tc)"
:hide-error-callout="showFix && tc.state === TOOL_CALL_STATE.ERROR"
:hide-error-callout="hideToolErrorCallout(tc)"
:has-content="toolStepView(tc).expandable"
>
<template v-for="view in [toolStepView(tc)]" :key="view.label">

View File

@ -0,0 +1,12 @@
import { TIME } from '@/app/constants/durations';
export function formatAgentElapsedTime(milliseconds: number): string {
const seconds = Number.isFinite(milliseconds)
? Math.max(0, Math.floor(milliseconds / TIME.SECOND))
: 0;
const minutes = Math.floor(seconds / 60);
const remainder = String(seconds % 60).padStart(2, '0');
return minutes < 60
? `${minutes}:${remainder}`
: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${remainder}`;
}

View File

@ -0,0 +1,205 @@
import { z } from 'zod';
import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import {
buildDisplayGroups,
type DisplayGroup,
} from '@/features/ai/shared/agentsChat/displayGroups';
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
import { TOOL_CALL_STATE } from '../constants';
const itemFields = {
id: z.string().uuid(),
title: z.string().min(1),
description: z.string().optional(),
resultSummary: z.string().optional(),
status: z.enum(['pending', 'in_progress', 'done', 'failed', 'cancelled']),
};
const taskSchema = z.object({ ...itemFields, kind: z.literal('task') });
const groupSchema = z.object({
...itemFields,
kind: z.literal('group'),
tasks: z.array(taskSchema),
});
const planSchema = z.object({
planId: z.string().uuid(),
revision: z.number().int().positive(),
closed: z.boolean(),
startedAt: z.string().datetime().nullable().optional(),
closedAt: z.string().datetime().nullable().optional(),
document: z.object({
title: z.string().min(1),
presentation: z
.object({ label: z.string().trim().min(1), detail: z.string().trim().min(1).optional() })
.optional(),
items: z.array(z.discriminatedUnion('kind', [taskSchema, groupSchema])),
}),
});
const planToolNames = new Set(['create_plan', 'read_plan', 'update_plan', 'close_plan']);
const planErrorSchema = z.union([z.string(), z.object({ error: z.string() })]);
export function isRecoverablePlanError(call: ToolCall): boolean {
if (
!planToolNames.has(call.tool) ||
call.tool === 'read_plan' ||
call.canceled ||
(call.state !== TOOL_CALL_STATE.ERROR && call.state !== TOOL_CALL_STATE.DONE)
) {
return false;
}
const result = planErrorSchema.safeParse(call.output);
if (!result.success) return false;
if (typeof result.data !== 'string' && ['invalid_plan', 'conflict'].includes(result.data.error)) {
return true;
}
if (call.state !== TOOL_CALL_STATE.ERROR) return false;
const message = typeof result.data === 'string' ? result.data : result.data.error;
return message
.replace(/^Error: /, '')
.startsWith(`AI_InvalidToolInputError: Invalid input for tool ${call.tool}:`);
}
export type AgentPlanView = z.infer<typeof planSchema>;
export type AgentPlanItemStatus = AgentPlanView['document']['items'][number]['status'];
const comparisonDocumentSchema = planSchema.shape.document.passthrough().extend({
items: z.array(
z.discriminatedUnion('kind', [
taskSchema.passthrough(),
groupSchema.passthrough().extend({ tasks: z.array(taskSchema.passthrough()) }),
]),
),
});
const comparisonPlanSchema = planSchema.extend({ document: comparisonDocumentSchema });
const updateInputSchema = z.object({
planId: z.string().uuid(),
expectedRevision: z.number().int().positive(),
document: comparisonDocumentSchema,
});
const progressFields = ['title', 'status', 'resultSummary', 'startedAt', 'endedAt'];
function planSubstance(document: z.infer<typeof comparisonDocumentSchema>) {
return {
...omit(document, ['presentation', 'items']),
items: document.items.map((item) => ({
...omit(item, progressFields),
...(item.kind === 'group'
? { tasks: item.tasks.map((task) => omit(task, progressFields)) }
: {}),
})),
};
}
export function buildAgentPlanDisplayGroups(messages: ChatMessage[]): DisplayGroup[] {
const plans = new Map<string, Map<number, z.infer<typeof comparisonPlanSchema>>>();
const hiddenCalls = new Set<string>();
for (const message of messages) {
for (const call of message.toolCalls ?? []) {
if (!planToolNames.has(call.tool)) continue;
hiddenCalls.delete(call.toolCallId);
if (call.canceled) continue;
if (call.state === TOOL_CALL_STATE.DONE && call.output !== undefined) {
if (call.tool === 'read_plan' && call.output === null) plans.clear();
const result = comparisonPlanSchema.safeParse(call.output);
if (!result.success) continue;
const current = result.data;
const revisions =
plans.get(current.planId) ?? new Map<number, z.infer<typeof comparisonPlanSchema>>();
const previous = revisions.get(current.revision - 1);
if (
call.tool === 'update_plan' &&
previous &&
!previous.closed &&
!current.closed &&
isEqual(planSubstance(previous.document), planSubstance(current.document))
) {
hiddenCalls.add(call.toolCallId);
}
revisions.set(current.revision, current);
plans.set(current.planId, revisions);
} else if (
call.tool === 'update_plan' &&
(call.state === TOOL_CALL_STATE.PENDING ||
call.state === TOOL_CALL_STATE.RUNNING ||
(call.state === TOOL_CALL_STATE.DONE && call.output === undefined))
) {
const input = updateInputSchema.safeParse(call.input);
if (!input.success) continue;
const previous = plans.get(input.data.planId)?.get(input.data.expectedRevision);
if (
previous &&
!previous.closed &&
isEqual(planSubstance(previous.document), planSubstance(input.data.document))
) {
hiddenCalls.add(call.toolCallId);
}
}
}
}
return buildDisplayGroups(messages).flatMap((group): DisplayGroup[] => {
if (group.kind === 'backgroundJobSignal') return [group];
if (group.kind === 'message') {
return [
{
...group,
message: {
...group.message,
toolCalls: group.message.toolCalls?.filter((call) => !hiddenCalls.has(call.toolCallId)),
},
},
];
}
const toolCalls = group.toolCalls.filter((call) => !hiddenCalls.has(call.toolCallId));
if (
!toolCalls.length &&
!group.active &&
!group.finalMessage?.content.trim() &&
!group.finalMessage?.attachments?.length &&
!group.finalMessage?.renderParts?.length &&
!group.thinkingSegments.length &&
!group.budgetNotices.length &&
!group.interactives.length
) {
return [];
}
return [{ ...group, toolCalls }];
});
}
export function selectLatestAgentPlan(messages: ChatMessage[]): AgentPlanView | null {
const plans = new Map<string, AgentPlanView>();
let current: AgentPlanView | null = null;
for (const message of messages) {
for (const call of message.toolCalls ?? []) {
if (!planToolNames.has(call.tool) || call.state !== TOOL_CALL_STATE.DONE || call.canceled) {
continue;
}
if (call.tool === 'read_plan' && call.output === null) {
current = null;
plans.clear();
continue;
}
const result = planSchema.safeParse(call.output);
if (!result.success) continue;
const previous = plans.get(result.data.planId);
current = previous && previous.revision >= result.data.revision ? previous : result.data;
plans.set(current.planId, current);
}
}
return current;
}