mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Show Agent plans in preview chat (no-changelog) (#40006)
This commit is contained in:
parent
5c0aff8cbc
commit
308ec2cc1c
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user