From 308ec2cc1c78d772fbddebc16ded0c3bb1f22355 Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 5 Oct 2026 15:42:28 +0000 Subject: [PATCH] feat(editor): Show Agent plans in preview chat (no-changelog) (#40006) --- .../plans/__tests__/agent-plan-tools.test.ts | 65 ++- .../modules/agents/plans/agent-plan-tools.ts | 9 + .../frontend/@n8n/i18n/src/locales/en.json | 9 + .../__tests__/AgentChatMessageList.test.ts | 146 ++++++- .../agents/__tests__/AgentChatPanel.test.ts | 94 +++++ .../agents/__tests__/AgentChatPlan.test.ts | 363 ++++++++++++++++ .../__tests__/AgentChatToolSteps.test.ts | 122 ++++++ .../agents/__tests__/agent-plan.test.ts | 387 ++++++++++++++++++ .../agents/__tests__/fixtures/agent-plan.ts | 46 +++ .../features/agents/agents-design-language.md | 9 + .../components/AgentChatMessageList.vue | 18 +- .../agents/components/AgentChatPanel.vue | 28 +- .../agents/components/AgentChatPlan.vue | 313 ++++++++++++++ .../agents/components/AgentChatToolSteps.vue | 11 +- .../agents/utils/agent-elapsed-time.ts | 12 + .../src/features/agents/utils/agent-plan.ts | 205 ++++++++++ 16 files changed, 1805 insertions(+), 32 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/agent-plan.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/fixtures/agent-plan.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentChatPlan.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/agent-elapsed-time.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/agent-plan.ts diff --git a/packages/cli/src/modules/agents/plans/__tests__/agent-plan-tools.test.ts b/packages/cli/src/modules/agents/plans/__tests__/agent-plan-tools.test.ts index 7f569f2ea4b..dccba3dcbca 100644 --- a/packages/cli/src/modules/agents/plans/__tests__/agent-plan-tools.test.ts +++ b/packages/cli/src/modules/agents/plans/__tests__/agent-plan-tools.test.ts @@ -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', diff --git a/packages/cli/src/modules/agents/plans/agent-plan-tools.ts b/packages/cli/src/modules/agents/plans/agent-plan-tools.ts index 18338faf56f..a42a59b1399 100644 --- a/packages/cli/src/modules/agents/plans/agent-plan-tools.ts +++ b/packages/cli/src/modules/agents/plans/agent-plan-tools.ts @@ -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: 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) => diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d7578fc752c..935811d05e1 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts index c1e1b5a21f0..88569726e28 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts @@ -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: '
' }, -})); - 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', () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index 6152029cb5c..fd5251bb87f 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -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 = { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts new file mode 100644 index 00000000000..1953c122a32 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPlan.test.ts @@ -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 }) => { + 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 = '\nSecond line'; + const wrapper = mount(AgentChatPlan, { + props: { + plan: planView({ + document: { + title: 'Plan', + presentation: { label: 'Checking sources', 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 = ''.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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts index ea6e8b58468..2241e5fa35a 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts @@ -25,6 +25,7 @@ vi.mock('@n8n/design-system', () => ({ · {{ part }} + ⚠
{{ error }}
@@ -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'], diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/agent-plan.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/agent-plan.test.ts new file mode 100644 index 00000000000..d2b7a410a18 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/agent-plan.test.ts @@ -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 = {}) => + 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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/fixtures/agent-plan.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/fixtures/agent-plan.ts new file mode 100644 index 00000000000..3704dd5d7de --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/fixtures/agent-plan.ts @@ -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 { + 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 = {}): ChatMessage { + const id = ++nextMessageId; + return { + id: `assistant-${id}`, + role: 'assistant', + content: '', + toolCalls: [ + { tool: 'create_plan', toolCallId: `plan-call-${id}`, state: 'done', output, ...overrides }, + ], + }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md index 1832fd77f96..ae077d3018e 100644 --- a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md +++ b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md @@ -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 diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue index 5589ab79036..1e51ae95b57 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue @@ -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('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( 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([]); @@ -1143,8 +1143,19 @@ onBeforeUnmount(() => { @stop="stopGenerating" @files-selected="handleFilesSelected" > -